Implement tenant announcements and audit log fixes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 14:19:46 +01:00
parent 412ecbe691
commit 8f13465415
33 changed files with 1400 additions and 117 deletions

View File

@@ -1,5 +1,5 @@
{"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"} {"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T17:23:28.230936323+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init","deleted_at":"2026-01-01T17:23:28.230936323+01:00","deleted_by":"soeren","delete_reason":"Remove stray stealth issue id","original_type":"task"}
{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-01T14:20:21.68206312+01:00"} {"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-02T14:18:31.676816348+01:00","closed_at":"2026-01-02T14:18:31.676816348+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"} {"id":"fotospiel-app-0h0","title":"SEC-BILL-02 Signature freshness + retry policies for Paddle webhooks","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:37.618780852+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:37.618780852+01:00"}
{"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-0rb","title":"Tenant admin onboarding: inline checkout integration in welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:22.434997456+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:28.026795975+01:00","closed_at":"2026-01-01T16:08:28.026795975+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-0u0","title":"Paddle migration: design Paddle data mappings for packages/products/prices","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:15.991704177+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:21.629616074+01:00","closed_at":"2026-01-01T15:57:21.629616074+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-0u0","title":"Paddle migration: design Paddle data mappings for packages/products/prices","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:15.991704177+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:21.629616074+01:00","closed_at":"2026-01-01T15:57:21.629616074+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +1 @@
fotospiel-app-iyc fotospiel-app-097

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\TenantAnnouncements\TenantAnnouncementService;
use Illuminate\Console\Command;
class DispatchTenantAnnouncements extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenant-announcements:dispatch';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Dispatch scheduled tenant announcements and queue email notifications';
/**
* Execute the console command.
*/
public function handle(TenantAnnouncementService $service): int
{
$result = $service->process();
$this->info(sprintf(
'Announcements: %d activated, %d archived, %d emails queued.',
$result['activated'],
$result['archived'],
$result['queued'],
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum TenantAnnouncementAudience: string
{
case ALL = 'all';
case TENANTS = 'tenants';
case SEGMENTS = 'segments';
public function label(): string
{
return match ($this) {
self::ALL => __('Alle Tenants'),
self::TENANTS => __('Ausgewählte Tenants'),
self::SEGMENTS => __('Segmente'),
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum TenantAnnouncementDeliveryStatus: string
{
case QUEUED = 'queued';
case FAILED = 'failed';
case SKIPPED = 'skipped';
public function label(): string
{
return match ($this) {
self::QUEUED => __('Warteschlange'),
self::FAILED => __('Fehlgeschlagen'),
self::SKIPPED => __('Übersprungen'),
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum TenantAnnouncementSegment: string
{
case ACTIVE_PACKAGE = 'active_package';
case ACTIVE_STATUS = 'active_status';
public function label(): string
{
return match ($this) {
self::ACTIVE_PACKAGE => __('Aktives Paket'),
self::ACTIVE_STATUS => __('Aktiv (nicht gesperrt/gelöscht)'),
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum TenantAnnouncementStatus: string
{
case DRAFT = 'draft';
case SCHEDULED = 'scheduled';
case ACTIVE = 'active';
case ARCHIVED = 'archived';
public function label(): string
{
return match ($this) {
self::DRAFT => __('Entwurf'),
self::SCHEDULED => __('Geplant'),
self::ACTIVE => __('Aktiv'),
self::ARCHIVED => __('Archiviert'),
};
}
}

View File

@@ -3,27 +3,41 @@
namespace App\Filament\Resources\EventResource\Pages; namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource; use App\Filament\Resources\EventResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page; use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class ManageWatermark extends Page class ManageWatermark extends Page
{ {
use InteractsWithRecord;
protected static string $resource = EventResource::class; protected static string $resource = EventResource::class;
protected string $view = 'filament.resources.event-resource.pages.manage-watermark'; protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
public ?string $watermark_mode = 'base'; public ?string $watermark_mode = 'base';
public ?string $watermark_asset = null; public ?string $watermark_asset = null;
public string $watermark_position = 'bottom-right'; public string $watermark_position = 'bottom-right';
public float $watermark_opacity = 0.25; public float $watermark_opacity = 0.25;
public float $watermark_scale = 0.2; public float $watermark_scale = 0.2;
public int $watermark_padding = 16; public int $watermark_padding = 16;
public bool $serve_originals = false; public bool $serve_originals = false;
public function mount(): void public function mount(int|string $record): void
{ {
$this->record = $this->resolveRecord($record);
$event = $this->record; $event = $this->record;
$settings = $event->settings ?? []; $settings = $event->settings ?? [];
$watermark = Arr::get($settings, 'watermark', []); $watermark = Arr::get($settings, 'watermark', []);
@@ -37,13 +51,10 @@ class ManageWatermark extends Page
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false); $this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
} }
protected function getForms(): array public function form(Schema $schema): Schema
{ {
return [ return $schema->schema([
'form' => $this->form( Section::make(__('filament-watermark.heading'))
$this->makeForm()
->schema([
Forms\Components\Fieldset::make(__('filament-watermark.heading'))
->schema([ ->schema([
Forms\Components\Select::make('watermark_mode') Forms\Components\Select::make('watermark_mode')
->label(__('filament-watermark.mode.label')) ->label(__('filament-watermark.mode.label'))
@@ -95,9 +106,7 @@ class ManageWatermark extends Page
->default(false), ->default(false),
]) ])
->columns(2), ->columns(2),
]) ]);
),
];
} }
public function save(): void public function save(): void
@@ -133,6 +142,17 @@ class ManageWatermark extends Page
$event->forceFill(['settings' => $settings])->save(); $event->forceFill(['settings' => $settings])->save();
$changed = array_diff(array_keys($event->getChanges()), ['updated_at']);
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'event.watermark_updated',
$event,
SuperAdminAuditLogger::fieldsMetadata($changed),
source: static::class
);
}
Notification::make() Notification::make()
->title(__('filament-watermark.saved')) ->title(__('filament-watermark.saved'))
->success() ->success()

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Filament\Resources;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Models\TenantAnnouncement;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class TenantAnnouncementResource extends Resource
{
protected static ?string $model = TenantAnnouncement::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
protected static ?string $recordTitleAttribute = 'title';
protected static ?int $navigationSort = 70;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function form(Schema $schema): Schema
{
$statusOptions = collect(TenantAnnouncementStatus::cases())
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
->all();
$audienceOptions = collect(TenantAnnouncementAudience::cases())
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
->all();
$segmentOptions = collect(TenantAnnouncementSegment::cases())
->mapWithKeys(fn (TenantAnnouncementSegment $segment) => [$segment->value => $segment->label()])
->all();
return $schema
->schema([
Section::make('Inhalt')
->schema([
TextInput::make('title')
->label('Titel')
->required()
->maxLength(160),
Textarea::make('body')
->label('Text')
->rows(6)
->required()
->columnSpanFull(),
TextInput::make('cta_label')
->label('CTA-Label')
->maxLength(160),
TextInput::make('cta_url')
->label('CTA-Link')
->maxLength(255)
->url()
->nullable(),
])
->columns(2),
Section::make('Zielgruppe')
->schema([
Select::make('audience')
->label('Zielgruppe')
->options($audienceOptions)
->default(TenantAnnouncementAudience::ALL->value)
->live()
->required(),
Select::make('tenants')
->label('Mandanten')
->relationship('tenants', 'name')
->multiple()
->preload()
->searchable()
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->columnSpanFull(),
CheckboxList::make('segments')
->label('Segmente')
->options($segmentOptions)
->columns(2)
->default([])
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->columnSpanFull(),
Toggle::make('email_enabled')
->label('E-Mail versenden')
->default(true),
])
->columns(2),
Section::make('Zeitplan')
->schema([
Select::make('status')
->label('Status')
->options($statusOptions)
->default(TenantAnnouncementStatus::DRAFT->value)
->live()
->required(),
DateTimePicker::make('starts_at')
->label('Startet am')
->seconds(false)
->nullable()
->required(fn (Get $get): bool => $get('status') === TenantAnnouncementStatus::SCHEDULED->value),
DateTimePicker::make('ends_at')
->label('Endet am')
->seconds(false)
->nullable(),
])
->columns(2),
])
->columns(1);
}
public static function table(Table $table): Table
{
$statusOptions = collect(TenantAnnouncementStatus::cases())
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
->all();
$audienceOptions = collect(TenantAnnouncementAudience::cases())
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
->all();
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->label('Titel')
->searchable()
->sortable()
->limit(50),
Tables\Columns\TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(function ($state): string {
if ($state instanceof TenantAnnouncementStatus) {
return $state->label();
}
return TenantAnnouncementStatus::tryFrom((string) $state)?->label() ?? (string) $state;
})
->sortable(),
Tables\Columns\TextColumn::make('audience')
->label('Zielgruppe')
->badge()
->formatStateUsing(function ($state): string {
if ($state instanceof TenantAnnouncementAudience) {
return $state->label();
}
return TenantAnnouncementAudience::tryFrom((string) $state)?->label() ?? (string) $state;
})
->sortable(),
Tables\Columns\IconColumn::make('email_enabled')
->label('E-Mail')
->boolean(),
Tables\Columns\TextColumn::make('starts_at')
->label('Start')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('ends_at')
->label('Ende')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->label('Aktualisiert')
->since()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('Status')
->options($statusOptions),
Tables\Filters\SelectFilter::make('audience')
->label('Zielgruppe')
->options($audienceOptions),
])
->actions([
Tables\Actions\EditAction::make()
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenantAnnouncements::route('/'),
'create' => Pages\CreateTenantAnnouncement::route('/create'),
'edit' => Pages\EditTenantAnnouncement::route('/{record}/edit'),
];
}
public static function mutateFormDataBeforeCreate(array $data): array
{
if ($userId = Auth::id()) {
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
}
return $data;
}
public static function mutateFormDataBeforeSave(array $data): array
{
if ($userId = Auth::id()) {
$data['updated_by'] = $userId;
}
return $data;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Filament\Resources\TenantAnnouncementResource;
class CreateTenantAnnouncement extends AuditedCreateRecord
{
protected static string $resource = TenantAnnouncementResource::class;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\TenantAnnouncementResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
class EditTenantAnnouncement extends AuditedEditRecord
{
protected static string $resource = TenantAnnouncementResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Filament\Resources\TenantAnnouncementResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTenantAnnouncements extends ListRecords
{
protected static string $resource = TenantAnnouncementResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -164,25 +164,16 @@ class GuestPolicySettingsPage extends Page
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours; $settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
$settings->save(); $settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record( app(SuperAdminAuditLogger::class)->record(
'guest_policy.updated', 'guest_policy.updated',
$settings, $settings,
SuperAdminAuditLogger::fieldsMetadata([ SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
'guest_downloads_enabled',
'guest_sharing_enabled',
'guest_upload_visibility',
'per_device_upload_limit',
'join_token_failure_limit',
'join_token_failure_decay_minutes',
'join_token_access_limit',
'join_token_access_decay_minutes',
'join_token_download_limit',
'join_token_download_decay_minutes',
'share_link_ttl_hours',
'guest_notification_ttl_hours',
]),
source: static::class source: static::class
); );
}
Notification::make() Notification::make()
->title(__('admin.guest_policy.notifications.saved')) ->title(__('admin.guest_policy.notifications.saved'))

View File

@@ -129,20 +129,16 @@ class WatermarkSettingsPage extends Page
$settings->offset_y = $this->offset_y; $settings->offset_y = $this->offset_y;
$settings->save(); $settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record( app(SuperAdminAuditLogger::class)->record(
'watermark_settings.updated', 'watermark_settings.updated',
$settings, $settings,
SuperAdminAuditLogger::fieldsMetadata([ SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
'asset',
'position',
'opacity',
'scale',
'padding',
'offset_x',
'offset_y',
]),
source: static::class source: static::class
); );
}
Notification::make() Notification::make()
->title('Wasserzeichen aktualisiert') ->title('Wasserzeichen aktualisiert')

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\TenantAnnouncementResource;
use App\Models\Tenant;
use App\Services\TenantAnnouncements\TenantAnnouncementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Arr;
class TenantAnnouncementController extends Controller
{
public function index(
Request $request,
TenantAnnouncementService $service
): AnonymousResourceCollection|JsonResponse {
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$decoded = $request->attributes->get('decoded_token', []);
$tenantId = Arr::get($decoded, 'tenant_id');
if ($tenantId) {
$tenant = Tenant::query()->find($tenantId);
}
}
if (! $tenant instanceof Tenant) {
return response()->json([
'message' => 'Tenant context missing.',
], 401);
}
$announcements = $service->visibleAnnouncementsForTenant($tenant);
return TenantAnnouncementResource::collection($announcements);
}
}

View File

@@ -311,32 +311,18 @@ class MarketingController extends Controller
public function blogIndex(Request $request, string $locale) public function blogIndex(Request $request, string $locale)
{ {
$locale = $locale ?: app()->getLocale(); $locale = $locale ?: app()->getLocale();
Log::info('Blog Index Debug - Initial', [
'locale' => $locale,
'full_url' => $request->fullUrl(),
]);
$query = BlogPost::query() $query = BlogPost::query()
->with('author') ->with('author')
->whereHas('category', function ($query) { ->whereHas('category', function ($query) {
$query->where('slug', 'blog'); $query->where('slug', 'blog');
}); });
$totalWithCategory = $query->count();
Log::info('Blog Index Debug - With Category', ['count' => $totalWithCategory]);
$query->where('is_published', true) $query->where('is_published', true)
->whereNotNull('published_at') ->whereNotNull('published_at')
->where('published_at', '<=', now()); ->where('published_at', '<=', now());
$totalPublished = $query->count();
Log::info('Blog Index Debug - Published', ['count' => $totalPublished]);
// Removed translation filter for now // Removed translation filter for now
$totalWithTranslation = $query->count();
Log::info('Blog Index Debug - With Translation', ['count' => $totalWithTranslation, 'locale' => $locale]);
$posts = $query->orderBy('published_at', 'desc') $posts = $query->orderBy('published_at', 'desc')
->paginate(4) ->paginate(4)
->through(function (BlogPost $post) use ($locale) { ->through(function (BlogPost $post) use ($locale) {
@@ -354,13 +340,6 @@ class MarketingController extends Controller
]; ];
}); });
Log::info('Blog Index Debug - Final Posts', [
'count' => $posts->count(),
'total' => $posts->total(),
'posts_data' => $posts->toArray(),
'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts',
]);
$postsArray = $posts->toArray(); $postsArray = $posts->toArray();
$postsArray['links'] = array_map(function (array $link) use ($locale) { $postsArray['links'] = array_map(function (array $link) use ($locale) {
return [ return [

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TenantAnnouncementResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'cta_label' => $this->cta_label,
'cta_url' => $this->cta_url,
'status' => $this->status?->value ?? $this->status,
'starts_at' => $this->starts_at?->toISOString(),
'ends_at' => $this->ends_at?->toISOString(),
'created_at' => $this->created_at?->toISOString(),
];
}
}

View File

@@ -70,6 +70,17 @@ class Tenant extends Model
return $this->hasMany(TenantPackage::class); return $this->hasMany(TenantPackage::class);
} }
public function announcements(): BelongsToMany
{
return $this->belongsToMany(TenantAnnouncement::class, 'tenant_announcement_targets')
->withTimestamps();
}
public function announcementDeliveries(): HasMany
{
return $this->hasMany(TenantAnnouncementDelivery::class);
}
public function packages(): BelongsToMany public function packages(): BelongsToMany
{ {
return $this->belongsToMany(Package::class, 'tenant_packages') return $this->belongsToMany(Package::class, 'tenant_packages')

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Models;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property array<int, string>|null $segments
*/
class TenantAnnouncement extends Model
{
/** @use HasFactory<\Database\Factories\TenantAnnouncementFactory> */
use HasFactory;
protected $fillable = [
'title',
'body',
'cta_label',
'cta_url',
'status',
'audience',
'segments',
'starts_at',
'ends_at',
'email_enabled',
'created_by',
'updated_by',
];
protected function casts(): array
{
return [
'status' => TenantAnnouncementStatus::class,
'audience' => TenantAnnouncementAudience::class,
'segments' => 'array',
'email_enabled' => 'boolean',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
}
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class, 'tenant_announcement_targets')
->withTimestamps();
}
public function deliveries(): HasMany
{
return $this->hasMany(TenantAnnouncementDelivery::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function scopeActive(Builder $query): void
{
$query->where('status', TenantAnnouncementStatus::ACTIVE->value)
->where(function (Builder $builder) {
$builder->whereNull('starts_at')
->orWhere('starts_at', '<=', Carbon::now());
})
->where(function (Builder $builder) {
$builder->whereNull('ends_at')
->orWhere('ends_at', '>', Carbon::now());
});
}
/**
* @param array<int, TenantAnnouncementSegment|string> $segments
*/
public function matchesSegments(Tenant $tenant, array $segments): bool
{
if ($segments === []) {
return true;
}
foreach ($segments as $segment) {
$normalized = $segment instanceof TenantAnnouncementSegment
? $segment
: TenantAnnouncementSegment::tryFrom((string) $segment);
if (! $normalized) {
continue;
}
if (! $this->matchesSegment($tenant, $normalized)) {
return false;
}
}
return true;
}
private function matchesSegment(Tenant $tenant, TenantAnnouncementSegment $segment): bool
{
return match ($segment) {
TenantAnnouncementSegment::ACTIVE_PACKAGE => $tenant->activeResellerPackage()->exists(),
TenantAnnouncementSegment::ACTIVE_STATUS => (bool) $tenant->is_active
&& ! (bool) $tenant->is_suspended
&& $tenant->pending_deletion_at === null
&& $tenant->anonymized_at === null,
};
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\TenantAnnouncementDeliveryStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantAnnouncementDelivery extends Model
{
protected $fillable = [
'tenant_announcement_id',
'tenant_id',
'channel',
'status',
'sent_at',
'failed_at',
'failure_reason',
];
protected function casts(): array
{
return [
'status' => TenantAnnouncementDeliveryStatus::class,
'sent_at' => 'datetime',
'failed_at' => 'datetime',
];
}
public function announcement(): BelongsTo
{
return $this->belongsTo(TenantAnnouncement::class, 'tenant_announcement_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Notifications;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TenantAnnouncementNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
private readonly TenantAnnouncement $announcement,
private readonly Tenant $tenant,
) {
$this->afterCommit();
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$lines = collect(preg_split('/\r\n|\r|\n/', $this->announcement->body ?? ''))
->map(fn ($line) => trim($line ?? ''))
->filter()
->values()
->all();
$ctaLabel = $this->announcement->cta_label ?: 'Event-Admin öffnen';
$ctaUrl = $this->announcement->cta_url ?: url('/event-admin');
return (new MailMessage)
->subject($this->announcement->title)
->view('emails.notifications.basic', [
'title' => $this->announcement->title,
'preheader' => $this->announcement->title,
'heroTitle' => $this->announcement->title,
'lines' => $lines,
'cta' => [
[
'label' => $ctaLabel,
'url' => $ctaUrl,
],
],
]);
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'announcement_id' => $this->announcement->getKey(),
'tenant_id' => $this->tenant->getKey(),
];
}
}

View File

@@ -49,6 +49,16 @@ class SuperAdminAuditLogger
?string $source = null, ?string $source = null,
?User $actor = null ?User $actor = null
): ?SuperAdminActionLog { ): ?SuperAdminActionLog {
if ($operation === 'updated') {
$changed = array_keys($record->getChanges());
if ($changed === []) {
return null;
}
$metadata = self::fieldsMetadata($changed);
}
$action = $this->formatAction($record, $operation); $action = $this->formatAction($record, $operation);
return $this->record( return $this->record(

View File

@@ -0,0 +1,234 @@
<?php
namespace App\Services\TenantAnnouncements;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementDeliveryStatus;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use App\Models\TenantAnnouncementDelivery;
use App\Notifications\TenantAnnouncementNotification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Throwable;
class TenantAnnouncementService
{
/**
* @return array{activated: int, archived: int, queued: int}
*/
public function process(): array
{
$activated = $this->activateScheduled();
$archived = $this->archiveExpired();
$queued = $this->dispatchActiveEmails();
return [
'activated' => $activated,
'archived' => $archived,
'queued' => $queued,
];
}
public function visibleAnnouncementsForTenant(Tenant $tenant): Collection
{
$baseQuery = TenantAnnouncement::query()->active();
$all = (clone $baseQuery)
->where('audience', TenantAnnouncementAudience::ALL->value)
->get();
$tenants = (clone $baseQuery)
->where('audience', TenantAnnouncementAudience::TENANTS->value)
->whereHas('tenants', fn (Builder $query) => $query->where('tenant_id', $tenant->id))
->get();
$segments = (clone $baseQuery)
->where('audience', TenantAnnouncementAudience::SEGMENTS->value)
->get()
->filter(function (TenantAnnouncement $announcement) use ($tenant): bool {
$segments = $this->normalizeSegments($announcement->segments ?? []);
return $announcement->matchesSegments($tenant, $segments);
});
return $all
->merge($tenants)
->merge($segments)
->sortByDesc(fn (TenantAnnouncement $announcement) => $announcement->starts_at ?? $announcement->created_at)
->values();
}
private function activateScheduled(): int
{
return TenantAnnouncement::query()
->where('status', TenantAnnouncementStatus::SCHEDULED->value)
->where(function (Builder $builder): void {
$builder->whereNull('starts_at')
->orWhere('starts_at', '<=', Carbon::now());
})
->update([
'status' => TenantAnnouncementStatus::ACTIVE->value,
'updated_at' => now(),
]);
}
private function archiveExpired(): int
{
return TenantAnnouncement::query()
->where('status', TenantAnnouncementStatus::ACTIVE->value)
->whereNotNull('ends_at')
->where('ends_at', '<', Carbon::now())
->update([
'status' => TenantAnnouncementStatus::ARCHIVED->value,
'updated_at' => now(),
]);
}
private function dispatchActiveEmails(): int
{
$announcements = TenantAnnouncement::query()
->active()
->where('email_enabled', true)
->get();
$queued = 0;
foreach ($announcements as $announcement) {
$queued += $this->dispatchEmailsForAnnouncement($announcement);
}
return $queued;
}
private function dispatchEmailsForAnnouncement(TenantAnnouncement $announcement): int
{
$tenants = $this->targetTenants($announcement);
if ($tenants->isEmpty()) {
return 0;
}
$deliveredTenantIds = TenantAnnouncementDelivery::query()
->where('tenant_announcement_id', $announcement->id)
->where('channel', 'mail')
->pluck('tenant_id')
->all();
$queued = 0;
foreach ($tenants as $tenant) {
if (in_array($tenant->id, $deliveredTenantIds, true)) {
continue;
}
$queued += $this->queueDelivery($announcement, $tenant);
}
return $queued;
}
private function queueDelivery(TenantAnnouncement $announcement, Tenant $tenant): int
{
$recipient = $this->resolveRecipient($tenant);
if (! $recipient) {
TenantAnnouncementDelivery::query()->create([
'tenant_announcement_id' => $announcement->id,
'tenant_id' => $tenant->id,
'channel' => 'mail',
'status' => TenantAnnouncementDeliveryStatus::SKIPPED->value,
'failed_at' => now(),
'failure_reason' => 'missing_recipient',
]);
return 0;
}
try {
Notification::route('mail', $recipient)
->notify(new TenantAnnouncementNotification($announcement, $tenant));
TenantAnnouncementDelivery::query()->create([
'tenant_announcement_id' => $announcement->id,
'tenant_id' => $tenant->id,
'channel' => 'mail',
'status' => TenantAnnouncementDeliveryStatus::QUEUED->value,
'sent_at' => now(),
]);
return 1;
} catch (Throwable $exception) {
TenantAnnouncementDelivery::query()->create([
'tenant_announcement_id' => $announcement->id,
'tenant_id' => $tenant->id,
'channel' => 'mail',
'status' => TenantAnnouncementDeliveryStatus::FAILED->value,
'failed_at' => now(),
'failure_reason' => class_basename($exception),
]);
}
return 0;
}
private function resolveRecipient(Tenant $tenant): ?string
{
$email = $tenant->contact_email ?: $tenant->user?->email;
return $email ?: null;
}
private function targetTenants(TenantAnnouncement $announcement): Collection
{
$query = Tenant::query()->with('user');
if ($announcement->audience === TenantAnnouncementAudience::TENANTS) {
$query->whereHas('announcements', fn (Builder $builder) => $builder->where('tenant_announcements.id', $announcement->id));
}
if ($announcement->audience === TenantAnnouncementAudience::SEGMENTS) {
$this->applySegmentFilters($query, $this->normalizeSegments($announcement->segments ?? []));
}
return $query->get();
}
/**
* @param array<int, TenantAnnouncementSegment|string> $segments
*/
private function applySegmentFilters(Builder $query, array $segments): void
{
foreach ($segments as $segment) {
$normalized = $segment instanceof TenantAnnouncementSegment
? $segment
: TenantAnnouncementSegment::tryFrom((string) $segment);
if (! $normalized) {
continue;
}
match ($normalized) {
TenantAnnouncementSegment::ACTIVE_PACKAGE => $query->whereHas('activeResellerPackage'),
TenantAnnouncementSegment::ACTIVE_STATUS => $query
->where('is_active', true)
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at'),
};
}
}
/**
* @param array<int, mixed> $segments
* @return array<int, TenantAnnouncementSegment|string>
*/
private function normalizeSegments(array $segments): array
{
return array_values(array_filter($segments, fn ($segment) => $segment !== null && $segment !== ''));
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TenantAnnouncement>
*/
class TenantAnnouncementFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => $this->faker->sentence(4),
'body' => $this->faker->paragraph(3),
'cta_label' => null,
'cta_url' => null,
'status' => TenantAnnouncementStatus::DRAFT,
'audience' => TenantAnnouncementAudience::ALL,
'segments' => [],
'email_enabled' => true,
'starts_at' => null,
'ends_at' => null,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_announcements', function (Blueprint $table) {
$table->id();
$table->string('title', 160);
$table->text('body');
$table->string('cta_label', 160)->nullable();
$table->string('cta_url', 255)->nullable();
$table->string('status', 24)->default('draft');
$table->string('audience', 24)->default('all');
$table->json('segments')->nullable();
$table->boolean('email_enabled')->default(true);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['status', 'starts_at']);
$table->index(['audience', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_announcements');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_announcement_deliveries', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_announcement_id')->constrained('tenant_announcements')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('channel', 24)->default('mail');
$table->string('status', 24)->default('queued');
$table->timestamp('sent_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->string('failure_reason', 255)->nullable();
$table->timestamps();
$table->unique(['tenant_announcement_id', 'tenant_id', 'channel'], 'tenant_announcement_delivery_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_announcement_deliveries');
}
};

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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_announcement_targets', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_announcement_id')->constrained('tenant_announcements')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->timestamps();
$table->unique(['tenant_announcement_id', 'tenant_id'], 'tenant_announcement_target_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tenant_announcement_targets');
}
};

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class TenantAnnouncementSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -27,6 +27,7 @@ use App\Http\Controllers\Api\Tenant\SettingsController;
use App\Http\Controllers\Api\Tenant\TaskCollectionController; use App\Http\Controllers\Api\Tenant\TaskCollectionController;
use App\Http\Controllers\Api\Tenant\TaskController; use App\Http\Controllers\Api\Tenant\TaskController;
use App\Http\Controllers\Api\Tenant\TenantAdminTokenController; use App\Http\Controllers\Api\Tenant\TenantAdminTokenController;
use App\Http\Controllers\Api\Tenant\TenantAnnouncementController;
use App\Http\Controllers\Api\Tenant\TenantFeedbackController; use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
use App\Http\Controllers\Api\TenantBillingController; use App\Http\Controllers\Api\TenantBillingController;
use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\Api\TenantPackageController;
@@ -162,6 +163,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('dashboard', DashboardController::class) Route::get('dashboard', DashboardController::class)
->middleware('tenant.admin') ->middleware('tenant.admin')
->name('tenant.dashboard'); ->name('tenant.dashboard');
Route::get('announcements', [TenantAnnouncementController::class, 'index'])
->middleware('tenant.admin')
->name('tenant.announcements.index');
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
Route::get('events', [EventController::class, 'index']) Route::get('events', [EventController::class, 'index'])

View File

@@ -27,3 +27,5 @@ Artisan::command('metrics:package-limits {--reset}', function () {
Schedule::command('model:prune', [ Schedule::command('model:prune', [
'--model' => [SuperAdminActionLog::class], '--model' => [SuperAdminActionLog::class],
])->daily(); ])->daily();
Schedule::command('tenant-announcements:dispatch')->everyFiveMinutes();

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Api\Tenant;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use App\Models\TenantPackage;
use Tests\Feature\Tenant\TenantTestCase;
class TenantAnnouncementsTest extends TenantTestCase
{
public function test_announcements_endpoint_returns_targeted_active_announcements(): void
{
$otherTenant = Tenant::factory()->create();
$activeAll = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::ALL,
]);
$targeted = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::TENANTS,
]);
$targeted->tenants()->attach($this->tenant->id);
$segmentActive = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::SEGMENTS,
'segments' => [TenantAnnouncementSegment::ACTIVE_STATUS->value],
]);
$segmentPackage = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::SEGMENTS,
'segments' => [TenantAnnouncementSegment::ACTIVE_PACKAGE->value],
]);
TenantPackage::factory()->for($this->tenant)->create(['active' => true]);
$otherTargeted = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::TENANTS,
]);
$otherTargeted->tenants()->attach($otherTenant->id);
$draft = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::DRAFT,
'audience' => TenantAnnouncementAudience::ALL,
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/announcements');
$response->assertOk();
$ids = collect($response->json('data'))->pluck('id');
$this->assertTrue($ids->contains($activeAll->id));
$this->assertTrue($ids->contains($targeted->id));
$this->assertTrue($ids->contains($segmentActive->id));
$this->assertTrue($ids->contains($segmentPackage->id));
$this->assertFalse($ids->contains($otherTargeted->id));
$this->assertFalse($ids->contains($draft->id));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Console;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementDeliveryStatus;
use App\Enums\TenantAnnouncementStatus;
use App\Models\Tenant;
use App\Models\TenantAnnouncement;
use App\Models\TenantAnnouncementDelivery;
use App\Models\User;
use App\Notifications\TenantAnnouncementNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class DispatchTenantAnnouncementsCommandTest extends TestCase
{
use RefreshDatabase;
public function test_dispatches_email_notifications_for_active_announcements(): void
{
Notification::fake();
$tenant = Tenant::factory()->create(['contact_email' => 'owner@example.com']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$tenant->user()->associate($user)->save();
$announcement = TenantAnnouncement::factory()->create([
'status' => TenantAnnouncementStatus::ACTIVE,
'audience' => TenantAnnouncementAudience::ALL,
'email_enabled' => true,
]);
$this->artisan('tenant-announcements:dispatch')->assertExitCode(0);
Notification::assertSentOnDemand(
TenantAnnouncementNotification::class,
function (TenantAnnouncementNotification $notification, array $channels, $notifiable) use ($announcement, $tenant): bool {
$payload = $notification->toArray($notifiable);
return in_array('mail', $channels, true)
&& $payload['announcement_id'] === $announcement->id
&& $payload['tenant_id'] === $tenant->id;
}
);
$this->assertTrue(TenantAnnouncementDelivery::query()
->where('tenant_announcement_id', $announcement->id)
->where('tenant_id', $tenant->id)
->where('channel', 'mail')
->where('status', TenantAnnouncementDeliveryStatus::QUEUED)
->exists());
}
}

View File

@@ -2,7 +2,9 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Filament\Resources\EventResource\Pages\ManageWatermark;
use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage; use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage;
use App\Models\Event;
use App\Models\SuperAdminActionLog; use App\Models\SuperAdminActionLog;
use App\Models\User; use App\Models\User;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@@ -29,6 +31,24 @@ class SuperAdminAuditLogSettingsTest extends TestCase
->exists()); ->exists());
} }
public function test_event_watermark_save_creates_audit_log(): void
{
$user = User::factory()->create(['role' => 'super_admin']);
$event = Event::factory()->create();
$this->bootSuperAdminPanel($user);
Livewire::test(ManageWatermark::class, ['record' => $event->getKey()])
->set('watermark_mode', 'base')
->set('watermark_position', 'top-left')
->call('save');
$this->assertTrue(SuperAdminActionLog::query()
->where('action', 'event.watermark_updated')
->where('subject_id', $event->id)
->exists());
}
private function bootSuperAdminPanel(User $user): void private function bootSuperAdminPanel(User $user): void
{ {
$panel = Filament::getPanel('superadmin'); $panel = Filament::getPanel('superadmin');