Implement tenant announcements and audit log fixes
This commit is contained in:
@@ -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":"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-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)"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-iyc
|
||||
fotospiel-app-097
|
||||
|
||||
40
app/Console/Commands/DispatchTenantAnnouncements.php
Normal file
40
app/Console/Commands/DispatchTenantAnnouncements.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
app/Enums/TenantAnnouncementAudience.php
Normal file
19
app/Enums/TenantAnnouncementAudience.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/TenantAnnouncementDeliveryStatus.php
Normal file
19
app/Enums/TenantAnnouncementDeliveryStatus.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
app/Enums/TenantAnnouncementSegment.php
Normal file
17
app/Enums/TenantAnnouncementSegment.php
Normal 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)'),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/TenantAnnouncementStatus.php
Normal file
21
app/Enums/TenantAnnouncementStatus.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,27 +3,41 @@
|
||||
namespace App\Filament\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EventResource;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ManageWatermark extends Page
|
||||
{
|
||||
use InteractsWithRecord;
|
||||
|
||||
protected static string $resource = EventResource::class;
|
||||
|
||||
protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
|
||||
|
||||
public ?string $watermark_mode = 'base';
|
||||
|
||||
public ?string $watermark_asset = null;
|
||||
|
||||
public string $watermark_position = 'bottom-right';
|
||||
|
||||
public float $watermark_opacity = 0.25;
|
||||
|
||||
public float $watermark_scale = 0.2;
|
||||
|
||||
public int $watermark_padding = 16;
|
||||
|
||||
public bool $serve_originals = false;
|
||||
|
||||
public function mount(): void
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
$this->record = $this->resolveRecord($record);
|
||||
|
||||
$event = $this->record;
|
||||
$settings = $event->settings ?? [];
|
||||
$watermark = Arr::get($settings, 'watermark', []);
|
||||
@@ -37,67 +51,62 @@ class ManageWatermark extends Page
|
||||
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return [
|
||||
'form' => $this->form(
|
||||
$this->makeForm()
|
||||
->schema([
|
||||
Forms\Components\Fieldset::make(__('filament-watermark.heading'))
|
||||
->schema([
|
||||
Forms\Components\Select::make('watermark_mode')
|
||||
->label(__('filament-watermark.mode.label'))
|
||||
->options([
|
||||
'base' => __('filament-watermark.mode.base'),
|
||||
'custom' => __('filament-watermark.mode.custom'),
|
||||
'off' => __('filament-watermark.mode.off'),
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\FileUpload::make('watermark_asset')
|
||||
->label(__('filament-watermark.asset'))
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->preserveFilenames()
|
||||
->image()
|
||||
->visible(fn (callable $get) => $get('watermark_mode') === 'custom'),
|
||||
Forms\Components\Select::make('watermark_position')
|
||||
->label(__('filament-watermark.position'))
|
||||
->options([
|
||||
'top-left' => 'Top Left',
|
||||
'top-right' => 'Top Right',
|
||||
'bottom-left' => 'Bottom Left',
|
||||
'bottom-right' => 'Bottom Right',
|
||||
'center' => 'Center',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_opacity')
|
||||
->label(__('filament-watermark.opacity'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(1)
|
||||
->step(0.05)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_scale')
|
||||
->label(__('filament-watermark.scale'))
|
||||
->numeric()
|
||||
->minValue(0.05)
|
||||
->maxValue(1)
|
||||
->step(0.05)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_padding')
|
||||
->label(__('filament-watermark.padding'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('serve_originals')
|
||||
->label(__('filament-watermark.serve_originals'))
|
||||
->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.')
|
||||
->default(false),
|
||||
])
|
||||
->columns(2),
|
||||
])
|
||||
),
|
||||
];
|
||||
return $schema->schema([
|
||||
Section::make(__('filament-watermark.heading'))
|
||||
->schema([
|
||||
Forms\Components\Select::make('watermark_mode')
|
||||
->label(__('filament-watermark.mode.label'))
|
||||
->options([
|
||||
'base' => __('filament-watermark.mode.base'),
|
||||
'custom' => __('filament-watermark.mode.custom'),
|
||||
'off' => __('filament-watermark.mode.off'),
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\FileUpload::make('watermark_asset')
|
||||
->label(__('filament-watermark.asset'))
|
||||
->disk('public')
|
||||
->directory('branding')
|
||||
->preserveFilenames()
|
||||
->image()
|
||||
->visible(fn (callable $get) => $get('watermark_mode') === 'custom'),
|
||||
Forms\Components\Select::make('watermark_position')
|
||||
->label(__('filament-watermark.position'))
|
||||
->options([
|
||||
'top-left' => 'Top Left',
|
||||
'top-right' => 'Top Right',
|
||||
'bottom-left' => 'Bottom Left',
|
||||
'bottom-right' => 'Bottom Right',
|
||||
'center' => 'Center',
|
||||
])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_opacity')
|
||||
->label(__('filament-watermark.opacity'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(1)
|
||||
->step(0.05)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_scale')
|
||||
->label(__('filament-watermark.scale'))
|
||||
->numeric()
|
||||
->minValue(0.05)
|
||||
->maxValue(1)
|
||||
->step(0.05)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('watermark_padding')
|
||||
->label(__('filament-watermark.padding'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('serve_originals')
|
||||
->label(__('filament-watermark.serve_originals'))
|
||||
->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.')
|
||||
->default(false),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
@@ -133,6 +142,17 @@ class ManageWatermark extends Page
|
||||
|
||||
$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()
|
||||
->title(__('filament-watermark.saved'))
|
||||
->success()
|
||||
|
||||
255
app/Filament/Resources/TenantAnnouncementResource.php
Normal file
255
app/Filament/Resources/TenantAnnouncementResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -164,25 +164,16 @@ class GuestPolicySettingsPage extends Page
|
||||
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
||||
$settings->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'guest_policy.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata([
|
||||
'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
|
||||
);
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'guest_policy.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.guest_policy.notifications.saved'))
|
||||
|
||||
@@ -129,20 +129,16 @@ class WatermarkSettingsPage extends Page
|
||||
$settings->offset_y = $this->offset_y;
|
||||
$settings->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'watermark_settings.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata([
|
||||
'asset',
|
||||
'position',
|
||||
'opacity',
|
||||
'scale',
|
||||
'padding',
|
||||
'offset_x',
|
||||
'offset_y',
|
||||
]),
|
||||
source: static::class
|
||||
);
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'watermark_settings.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Wasserzeichen aktualisiert')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -311,32 +311,18 @@ class MarketingController extends Controller
|
||||
public function blogIndex(Request $request, string $locale)
|
||||
{
|
||||
$locale = $locale ?: app()->getLocale();
|
||||
Log::info('Blog Index Debug - Initial', [
|
||||
'locale' => $locale,
|
||||
'full_url' => $request->fullUrl(),
|
||||
]);
|
||||
|
||||
$query = BlogPost::query()
|
||||
->with('author')
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('slug', 'blog');
|
||||
});
|
||||
|
||||
$totalWithCategory = $query->count();
|
||||
Log::info('Blog Index Debug - With Category', ['count' => $totalWithCategory]);
|
||||
|
||||
$query->where('is_published', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
|
||||
$totalPublished = $query->count();
|
||||
Log::info('Blog Index Debug - Published', ['count' => $totalPublished]);
|
||||
|
||||
// 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')
|
||||
->paginate(4)
|
||||
->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['links'] = array_map(function (array $link) use ($locale) {
|
||||
return [
|
||||
|
||||
29
app/Http/Resources/Tenant/TenantAnnouncementResource.php
Normal file
29
app/Http/Resources/Tenant/TenantAnnouncementResource.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,17 @@ class Tenant extends Model
|
||||
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
|
||||
{
|
||||
return $this->belongsToMany(Package::class, 'tenant_packages')
|
||||
|
||||
121
app/Models/TenantAnnouncement.php
Normal file
121
app/Models/TenantAnnouncement.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Models/TenantAnnouncementDelivery.php
Normal file
39
app/Models/TenantAnnouncementDelivery.php
Normal 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);
|
||||
}
|
||||
}
|
||||
78
app/Notifications/TenantAnnouncementNotification.php
Normal file
78
app/Notifications/TenantAnnouncementNotification.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,16 @@ class SuperAdminAuditLogger
|
||||
?string $source = null,
|
||||
?User $actor = null
|
||||
): ?SuperAdminActionLog {
|
||||
if ($operation === 'updated') {
|
||||
$changed = array_keys($record->getChanges());
|
||||
|
||||
if ($changed === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$metadata = self::fieldsMetadata($changed);
|
||||
}
|
||||
|
||||
$action = $this->formatAction($record, $operation);
|
||||
|
||||
return $this->record(
|
||||
|
||||
234
app/Services/TenantAnnouncements/TenantAnnouncementService.php
Normal file
234
app/Services/TenantAnnouncements/TenantAnnouncementService.php
Normal 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 !== ''));
|
||||
}
|
||||
}
|
||||
34
database/factories/TenantAnnouncementFactory.php
Normal file
34
database/factories/TenantAnnouncementFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
16
database/seeders/TenantAnnouncementSeeder.php
Normal file
16
database/seeders/TenantAnnouncementSeeder.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
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\TenantBillingController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
@@ -162,6 +163,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::get('dashboard', DashboardController::class)
|
||||
->middleware('tenant.admin')
|
||||
->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('events', [EventController::class, 'index'])
|
||||
|
||||
@@ -27,3 +27,5 @@ Artisan::command('metrics:package-limits {--reset}', function () {
|
||||
Schedule::command('model:prune', [
|
||||
'--model' => [SuperAdminActionLog::class],
|
||||
])->daily();
|
||||
|
||||
Schedule::command('tenant-announcements:dispatch')->everyFiveMinutes();
|
||||
|
||||
68
tests/Feature/Api/Tenant/TenantAnnouncementsTest.php
Normal file
68
tests/Feature/Api/Tenant/TenantAnnouncementsTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Resources\EventResource\Pages\ManageWatermark;
|
||||
use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage;
|
||||
use App\Models\Event;
|
||||
use App\Models\SuperAdminActionLog;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
@@ -29,6 +31,24 @@ class SuperAdminAuditLogSettingsTest extends TestCase
|
||||
->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
|
||||
{
|
||||
$panel = Filament::getPanel('superadmin');
|
||||
|
||||
Reference in New Issue
Block a user