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

@@ -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')

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);
}
}