Rechtlich oder redaktionell veranlasste Entfernungen brauchten bisher die Löschung — danach antwortet die URL mit 404, als hätte es die Meldung nie gegeben. Newsroom und Pressemitteilung lassen sich jetzt stattdessen mit gone_at markieren: Der Inhalt verschwindet aus allen öffentlichen Listen, die bestehende URL liefert aber einen Tombstone mit Status 410. Die vollständige Löschung bleibt unverändert daneben stehen und erzeugt weiterhin 404. Umkehrbar heißt hier wirklich umkehrbar. markGone() überschreibt zwei Felder — companies.is_active (damit der Newsroom ohne Sonderbehandlung aus Newsroom-Liste und Sitemap fällt, die beide darauf filtern) und press_releases.no_export. Beide Vorzustände werden gesichert und beim Aufheben zurückgestellt, sonst stünde eine schon vorher deaktivierte Firma danach öffentlich in der Übersicht und eine Mitteilung dauerhaft als "kein Export" da, ohne dass das je jemand gewählt hätte. Der Auslieferungs-Cache kennt keine Tags, und die Keys der Listenseiten sind über Portal, Sprache, Seite und Filter verstreut. Eine Entfernung erhöht deshalb eine Generations-Nummer im Cache-Key und verwirft damit alle Seiten-Caches auf einen Schlag — grob, aber Entfernungen sind selten und dürfen nicht bis zu einer Stunde nachhängen. Der Zähler liegt bewusst außerhalb des Namensraums, den er invalidiert, und wird zwischen zwei Queue-Jobs neu gelesen: Ein Worker lebt über beliebig viele Jobs und arbeitete sonst dauerhaft mit dem Stand seines ersten Zugriffs. Beim Newsroom läuft die Tombstone-Prüfung bewusst VOR dem Seiten-Cache — eine Entfernung muss sofort greifen, auch wenn für den Slug noch ein Stale-Eintrag liegt. Kaskadiert man die Entfernung auf die Meldungen einer Firma, wird der Cache einmal am Ende verworfen statt einmal je Meldung; sonst zwänge eine Bestandsfirma mit dreistellig vielen Meldungen die gesamte öffentliche Seite ebenso oft in den Neuaufbau. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
768 lines
27 KiB
PHP
768 lines
27 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Enums\ActivityType;
|
||
use App\Enums\AiTextDeclaration;
|
||
use App\Enums\BoostPlacement;
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseClassification;
|
||
use App\Enums\PressReleaseContentTier;
|
||
use App\Enums\PressReleaseEventType;
|
||
use App\Enums\PressReleasePlaceholder;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Http\Middleware\SetEdition;
|
||
use App\Models\Concerns\HasUniqueSlug;
|
||
use App\Scopes\PortalScope;
|
||
use App\Services\ActivityLog;
|
||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||
use Closure;
|
||
use Database\Factories\PressReleaseFactory;
|
||
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\Database\Eloquent\SoftDeletes;
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Pagination\Paginator;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\HtmlString;
|
||
use Illuminate\Support\Str;
|
||
|
||
class PressRelease extends Model
|
||
{
|
||
/** @use HasFactory<PressReleaseFactory> */
|
||
use HasFactory, HasUniqueSlug, SoftDeletes;
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
protected function slugScopeAttributes(): array
|
||
{
|
||
return ['portal', 'language'];
|
||
}
|
||
|
||
protected function slugFallback(): string
|
||
{
|
||
return 'pressemitteilung';
|
||
}
|
||
|
||
protected static function booted(): void
|
||
{
|
||
static::addGlobalScope(new PortalScope);
|
||
|
||
static::creating(function (self $pressRelease): void {
|
||
if (blank($pressRelease->placeholder_variant)) {
|
||
$seed = $pressRelease->uuid ?? $pressRelease->title ?? (string) now()->timestamp;
|
||
$pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed)->value;
|
||
}
|
||
});
|
||
}
|
||
|
||
protected $fillable = [
|
||
'uuid',
|
||
'portal',
|
||
'user_id',
|
||
'company_id',
|
||
'category_id',
|
||
'language',
|
||
'title',
|
||
'subtitle',
|
||
'slug',
|
||
'text',
|
||
'boilerplate_override',
|
||
'placeholder_variant',
|
||
'backlink_url',
|
||
'keywords',
|
||
'ai_text_declaration',
|
||
'ai_declaration_confirmed_at',
|
||
'status',
|
||
'classification',
|
||
'classified_at',
|
||
'content_score',
|
||
'content_tier',
|
||
'scored_at',
|
||
'hits',
|
||
'teaser_begin',
|
||
'teaser_end',
|
||
'no_export',
|
||
'published_at',
|
||
'depublished_at',
|
||
'gone_at',
|
||
'no_export_before_gone',
|
||
'scheduled_at',
|
||
'embargo_at',
|
||
'withdrawal_waiver_consented_at',
|
||
'legacy_portal',
|
||
'legacy_id',
|
||
];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'portal' => Portal::class,
|
||
'placeholder_variant' => PressReleasePlaceholder::class,
|
||
'ai_text_declaration' => AiTextDeclaration::class,
|
||
'ai_declaration_confirmed_at' => 'datetime',
|
||
'status' => PressReleaseStatus::class,
|
||
'classification' => PressReleaseClassification::class,
|
||
'classified_at' => 'datetime',
|
||
'content_score' => 'integer',
|
||
'content_tier' => PressReleaseContentTier::class,
|
||
'scored_at' => 'datetime',
|
||
'hits' => 'integer',
|
||
'teaser_begin' => 'integer',
|
||
'teaser_end' => 'integer',
|
||
'no_export' => 'boolean',
|
||
'published_at' => 'datetime',
|
||
'depublished_at' => 'datetime',
|
||
'gone_at' => 'datetime',
|
||
'no_export_before_gone' => 'boolean',
|
||
'sync_locked_at' => 'datetime',
|
||
'scheduled_at' => 'datetime',
|
||
'embargo_at' => 'datetime',
|
||
'withdrawal_waiver_consented_at' => 'datetime',
|
||
'deleted_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Geplanter Veröffentlichungstermin. Die Anwendung läuft durchgängig in
|
||
* Europe/Berlin (config app.timezone), daher ist keine Umrechnung nötig.
|
||
*/
|
||
public function scheduledAtLocal(): ?Carbon
|
||
{
|
||
return $this->scheduled_at;
|
||
}
|
||
|
||
/**
|
||
* Re-Sync-Sperre (Meldeverfahren): Nach einer Mutation über das
|
||
* Meldeverfahren darf kein Legacy-Import/API-Re-Sync die PM mehr
|
||
* überschreiben — sonst kommt der alte Stand (z. B. ein geschwärzter
|
||
* Klarname) zurück. Idempotent; die erste Sperre bleibt maßgeblich.
|
||
*/
|
||
public function lockSync(string $reason): void
|
||
{
|
||
if ($this->isSyncLocked()) {
|
||
return;
|
||
}
|
||
|
||
$this->forceFill([
|
||
'sync_locked_at' => now(),
|
||
'sync_lock_reason' => Str::limit($reason, 250),
|
||
])->save();
|
||
|
||
app(ActivityLog::class)->record(
|
||
ActivityType::SyncLock,
|
||
sprintf('Re-Sync-Sperre gesetzt: %s', $reason),
|
||
subject: $this,
|
||
actorLabel: 'System',
|
||
);
|
||
}
|
||
|
||
public function isSyncLocked(): bool
|
||
{
|
||
return $this->sync_locked_at !== null;
|
||
}
|
||
|
||
/**
|
||
* Hinterlegte Pressekontaktadresse für das Meldeverfahren: erst der
|
||
* PM-Kontakt, dann die Firmen-Adresse. Bewusst ohne PortalScope — die
|
||
* Meldeseite läuft host-agnostisch auf allen Domains.
|
||
*/
|
||
public function pressContactEmail(): ?string
|
||
{
|
||
$contactEmail = $this->contacts()
|
||
->withoutGlobalScopes()
|
||
->whereNotNull('email')
|
||
->where('email', '!=', '')
|
||
->value('email');
|
||
|
||
if (is_string($contactEmail) && $contactEmail !== '') {
|
||
return $contactEmail;
|
||
}
|
||
|
||
// Legacy-Importe: Kontakte hängen an der Firma, nicht an der PM —
|
||
// ohne PM-Kontakt zählt der Firmen-Pressekontakt als Kanal.
|
||
if ($this->company_id) {
|
||
$companyContactEmail = Contact::query()
|
||
->withoutGlobalScopes()
|
||
->where('company_id', $this->company_id)
|
||
->whereNotNull('email')
|
||
->where('email', '!=', '')
|
||
->orderBy('id')
|
||
->value('email');
|
||
|
||
if (is_string($companyContactEmail) && $companyContactEmail !== '') {
|
||
return $companyContactEmail;
|
||
}
|
||
}
|
||
|
||
$companyEmail = $this->company()->withoutGlobalScopes()->value('email');
|
||
|
||
return is_string($companyEmail) && $companyEmail !== '' ? $companyEmail : null;
|
||
}
|
||
|
||
/**
|
||
* Sperrfrist (Embargo) in der Anwendungs-Zeitzone (Europe/Berlin).
|
||
*/
|
||
public function embargoAtLocal(): ?Carbon
|
||
{
|
||
return $this->embargo_at;
|
||
}
|
||
|
||
/**
|
||
* URL der explizit gewählten Platzhalter-Grafik für bildlose PMs.
|
||
* Null bei Altbeständen ohne gesetzte Variante — dort rendert das
|
||
* Web-Frontend weiterhin seinen generischen Fallback.
|
||
*/
|
||
public function chosenPlaceholderUrl(): ?string
|
||
{
|
||
return $this->placeholder_variant
|
||
? asset($this->placeholder_variant->path())
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* Kategorie-Name in der Anzeigesprache, Fallback auf die erste
|
||
* vorhandene Übersetzung. Zentrale Ablöse der zuvor in jeder
|
||
* Kartenkomponente kopierten Auflösung (R4.3).
|
||
*/
|
||
public function localizedCategoryName(?string $locale = null): ?string
|
||
{
|
||
$locale ??= app()->getLocale();
|
||
|
||
$translation = $this->category?->translations?->firstWhere('locale', $locale)
|
||
?? $this->category?->translations?->first();
|
||
|
||
return $translation?->name;
|
||
}
|
||
|
||
/**
|
||
* Bild-URL für Kartenansichten: card-Variante des ersten Bildes,
|
||
* Fallback Original. Null bei bildlosen PMs (dann greift
|
||
* chosenPlaceholderUrl() bzw. der generische Fallback der Komponente).
|
||
*/
|
||
public function cardImageUrl(): ?string
|
||
{
|
||
$image = $this->images?->first();
|
||
|
||
return $image?->variantUrl('card') ?? $image?->url();
|
||
}
|
||
|
||
/**
|
||
* Bild-URL für Listen-Thumbnails: thumb-Variante des Titelbildes,
|
||
* Fallback card-Variante bzw. Original. Null bei bildlosen PMs
|
||
* (dann greift chosenPlaceholderUrl() bzw. der generische Fallback).
|
||
*/
|
||
public function thumbImageUrl(): ?string
|
||
{
|
||
$image = $this->images?->first();
|
||
|
||
return $image?->variantUrl('thumb') ?? $image?->variantUrl('card') ?? $image?->url();
|
||
}
|
||
|
||
/**
|
||
* Tag-freier, whitespace-normalisierter Anriss des PM-Textes.
|
||
*/
|
||
public function teaserText(int $limit = 180): string
|
||
{
|
||
return (string) Str::of(strip_tags((string) $this->text))->squish()->limit($limit);
|
||
}
|
||
|
||
public function wordCount(): int
|
||
{
|
||
return str_word_count(strip_tags((string) $this->text));
|
||
}
|
||
|
||
/**
|
||
* Lesezeit in Minuten (200 Wörter/Minute, mindestens 1).
|
||
*/
|
||
public function readTimeMinutes(): int
|
||
{
|
||
return max(1, (int) round($this->wordCount() / 200));
|
||
}
|
||
|
||
/**
|
||
* Kompakter Zeitstempel der Kartenkomponenten: heute „HH:MM",
|
||
* gestern lokalisiert, sonst Kurzdatum. Null ohne published_at.
|
||
*/
|
||
public function compactPublishedLabel(): ?string
|
||
{
|
||
$publishedAt = $this->published_at;
|
||
|
||
if (! $publishedAt) {
|
||
return null;
|
||
}
|
||
|
||
if ($publishedAt->isToday()) {
|
||
return $publishedAt->format('H:i');
|
||
}
|
||
|
||
if ($publishedAt->isYesterday()) {
|
||
return __('web.shared.yesterday');
|
||
}
|
||
|
||
return $publishedAt->isoFormat('D. MMM');
|
||
}
|
||
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
|
||
public function company(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Company::class);
|
||
}
|
||
|
||
public function category(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Category::class);
|
||
}
|
||
|
||
public function images(): HasMany
|
||
{
|
||
return $this->hasMany(PressReleaseImage::class);
|
||
}
|
||
|
||
public function attachments(): HasMany
|
||
{
|
||
return $this->hasMany(PressReleaseAttachment::class);
|
||
}
|
||
|
||
public function contacts(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(Contact::class, 'press_release_contact');
|
||
}
|
||
|
||
public function statusLogs(): HasMany
|
||
{
|
||
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
||
}
|
||
|
||
/**
|
||
* Ereignis-Log (Aufrufe/Shares) — Aufruf-Gesamtzähler bleibt `hits`.
|
||
*/
|
||
public function events(): HasMany
|
||
{
|
||
return $this->hasMany(PressReleaseEvent::class);
|
||
}
|
||
|
||
/**
|
||
* Zählt einen Aufruf atomar (increment `hits`) und schreibt ein View-Event.
|
||
* Zentral, weil sowohl die Web-Detailseite als auch die Legacy-API dies
|
||
* per `defer()` nach der Response ausführen. Das Event wird direkt über die
|
||
* bekannte ID angelegt (kein erneutes Laden der PM).
|
||
*/
|
||
public static function recordDeferredView(int $pressReleaseId): void
|
||
{
|
||
// Bewusst NUR das Ereignis-Log (kleine Append-Tabelle). Der frühere
|
||
// `increment('hits')` traf die über 1 GB große press_releases-Tabelle
|
||
// und blockierte dort, sobald eine Schemaänderung lief — die
|
||
// wartenden Requests belegten PHP-FPM-Worker und DB-Verbindungen, bis
|
||
// die gesamte Seite stand. Die Summierung übernimmt jetzt gebündelt
|
||
// der Scheduler (AggregatePressReleaseViews).
|
||
//
|
||
// Der Aufruf-Zähler ist eine Nebensache: Er darf eine Seite niemals
|
||
// langsamer oder kaputt machen, deshalb wird jeder Fehler geschluckt
|
||
// und nur gemeldet.
|
||
try {
|
||
PressReleaseEvent::create([
|
||
'press_release_id' => $pressReleaseId,
|
||
'type' => PressReleaseEventType::View,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
report($e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) —
|
||
* jeder Submit erzeugt einen neuen, unveränderlichen Eintrag.
|
||
*/
|
||
public function aiDeclarations(): HasMany
|
||
{
|
||
return $this->hasMany(PressReleaseAiDeclaration::class)->orderByDesc('created_at');
|
||
}
|
||
|
||
/**
|
||
* Öffentliches KI-Label in Einreicher-Stimme — null, wenn nach dem
|
||
* deklarierten KI-Grad kein Pflicht-Label fällig ist (ohne KI /
|
||
* Formulierungshilfe) oder noch keine Deklaration vorliegt.
|
||
*/
|
||
public function aiTextPublicLabel(): ?string
|
||
{
|
||
return $this->ai_text_declaration?->publicLabel();
|
||
}
|
||
|
||
/**
|
||
* Öffentliche Detailseiten je Portal, auf dem die PM erscheint
|
||
* (bei Portal::Both beide Spokes). Zentrale Quelle für Mail,
|
||
* Nachweis-PDF und Detailansicht.
|
||
*
|
||
* @return list<array{portal: Portal, label: string, url: string}>
|
||
*/
|
||
public function publicPortalLinks(): array
|
||
{
|
||
if (! $this->slug) {
|
||
return [];
|
||
}
|
||
|
||
$portals = match ($this->portal) {
|
||
Portal::Both => [Portal::Presseecho, Portal::Businessportal24],
|
||
null => [],
|
||
default => [$this->portal],
|
||
};
|
||
|
||
$edition = in_array($this->language, SetEdition::EDITIONS, true)
|
||
? $this->language
|
||
: 'de';
|
||
|
||
return array_map(fn (Portal $portal): array => [
|
||
'portal' => $portal,
|
||
'label' => $portal->label(),
|
||
'url' => sprintf(
|
||
'%s/%s/press-release/%s',
|
||
rtrim((string) match ($portal) {
|
||
Portal::Presseecho => config('domains.domain_presseecho_url'),
|
||
default => config('domains.domain_businessportal_url'),
|
||
}, '/'),
|
||
$edition,
|
||
$this->slug,
|
||
),
|
||
], $portals);
|
||
}
|
||
|
||
public function kiAudits(): HasMany
|
||
{
|
||
return $this->hasMany(KiAudit::class)->orderByDesc('created_at');
|
||
}
|
||
|
||
public function boosts(): HasMany
|
||
{
|
||
return $this->hasMany(Boost::class);
|
||
}
|
||
|
||
/**
|
||
* Aktuell geboostet (mindestens ein laufender Boost-Zeitraum)?
|
||
*/
|
||
public function isBoosted(): bool
|
||
{
|
||
return $this->boosts()->active()->exists();
|
||
}
|
||
|
||
/**
|
||
* Beschränkt auf PMs mit aktuell laufendem Boost — Basis für die
|
||
* Featured-Platzierung auf Start-/Branchenseite. Optional auf eine
|
||
* Boost-Stufe eingeschränkt (Startseite zeigt nur Startseiten-Boosts).
|
||
*/
|
||
public function scopeBoosted(Builder $query, ?BoostPlacement $placement = null): Builder
|
||
{
|
||
return $query->whereHas('boosts', fn (Builder $q) => $q
|
||
->active()
|
||
->when($placement, fn (Builder $q) => $q->forPlacement($placement)));
|
||
}
|
||
|
||
/**
|
||
* Eine veröffentlichte PM: freigegebener Status mit gesetztem Datum. Bewusst
|
||
* OHNE Zeitschranke, damit Statistik-Fenster (z. B. „letzte 7 Tage") frei auf
|
||
* `published_at` einschränken können. Single Source of Truth für die
|
||
* öffentlichen Web-Abfragen.
|
||
*/
|
||
public function scopePublished(Builder $query): Builder
|
||
{
|
||
return $query->where('status', PressReleaseStatus::Published)
|
||
->whereNotNull('published_at');
|
||
}
|
||
|
||
/**
|
||
* Öffentlich sichtbare PM im Archiv: veröffentlicht UND der Termin liegt nicht
|
||
* in der Zukunft (`published_at <= jetzt`). Basis für alle Listen-/Detail-/
|
||
* Lead-Abfragen der Web-Seiten.
|
||
*/
|
||
public function scopeVisibleInArchive(Builder $query): Builder
|
||
{
|
||
return $query->published()->where('published_at', '<=', now())->whereNull('gone_at');
|
||
}
|
||
|
||
/**
|
||
* Mit 410 entfernt: die URL bleibt bestehen und liefert einen Tombstone,
|
||
* der Inhalt ist aber nicht mehr öffentlich. Gegenstück zur vollständigen
|
||
* Löschung (Soft Delete), die 404 erzeugt.
|
||
*/
|
||
public function isGone(): bool
|
||
{
|
||
return $this->gone_at !== null;
|
||
}
|
||
|
||
/**
|
||
* Instanz-Pendant zu scopeVisibleInArchive(): wertet die öffentliche
|
||
* Sichtbarkeit aus den bereits geladenen Attributen aus, ohne erneute
|
||
* DB-Abfrage (für die hochfrequenten Medienrouten).
|
||
*/
|
||
public function isVisibleInArchive(): bool
|
||
{
|
||
return $this->status === PressReleaseStatus::Published
|
||
&& $this->published_at !== null
|
||
&& $this->published_at->lessThanOrEqualTo(now())
|
||
&& $this->gone_at === null;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $portalValues
|
||
*/
|
||
public function scopeForPortalValues(Builder $query, array $portalValues): Builder
|
||
{
|
||
return $query->whereIn('portal', $portalValues);
|
||
}
|
||
|
||
public function scopeForLanguage(Builder $query, string $language): Builder
|
||
{
|
||
return $query->where('language', $language);
|
||
}
|
||
|
||
/**
|
||
* Suchtreffer der öffentlichen Archiv-Suche. Auf MySQL läuft die Suche
|
||
* über den Volltext-Index (press_releases_web_search_fulltext) im
|
||
* Boolean-Mode: jedes Wort muss vorkommen (+wort*), Präfix-Treffer
|
||
* inklusive. Kurze Wörter (< 3 Zeichen, unterhalb von
|
||
* innodb_ft_min_token_size) und andere Treiber (SQLite-Tests) fallen auf
|
||
* wortweises LIKE über dieselben Spalten zurück. Firmenname und Rubrik-
|
||
* name der Ausgabe werden zusätzlich mitgesucht — als vorab aufgelöste
|
||
* ID-Listen statt `orWhereHas`, weil OR + korreliertes EXISTS den
|
||
* Volltext-Index aushebelt (gemessen ~2 s statt ~0,5 s bei 175k PMs).
|
||
*/
|
||
public function scopeMatchingSearchTerm(Builder $query, string $term, string $language): Builder
|
||
{
|
||
$tokens = collect(preg_split('/\s+/u', trim($term), -1, PREG_SPLIT_NO_EMPTY) ?: [])
|
||
->map(fn (string $token): string => (string) preg_replace('/[+\-><()~*"@]+/u', '', $token))
|
||
->filter()
|
||
->values();
|
||
|
||
$useFullText = DB::connection()->getDriverName() === 'mysql'
|
||
&& $tokens->isNotEmpty()
|
||
&& $tokens->every(fn (string $token): bool => mb_strlen($token) >= 3);
|
||
|
||
$like = '%'.addcslashes($term, '\\%_').'%';
|
||
|
||
$companyIds = Company::query()->whereLike('name', $like)->pluck('id');
|
||
|
||
$categoryIds = Category::query()
|
||
->whereHas('translations', fn (Builder $translation) => $translation
|
||
->where('locale', $language)
|
||
->whereLike('name', $like))
|
||
->pluck('id');
|
||
|
||
return $query->where(function (Builder $outer) use ($tokens, $useFullText, $companyIds, $categoryIds): void {
|
||
$outer->where(function (Builder $content) use ($tokens, $useFullText): void {
|
||
if ($useFullText) {
|
||
// Der Volltext-Index liegt auf der Suchtabelle, nicht auf
|
||
// press_releases — sonst wäre dort jedes ADD COLUMN ein
|
||
// stundenlanger Rebuild (siehe PressReleaseSearchIndex).
|
||
PressReleaseSearchIndex::constrain(
|
||
$content,
|
||
['title', 'subtitle', 'text', 'keywords'],
|
||
$tokens->map(fn (string $token): string => '+'.$token.'*')->implode(' '),
|
||
['mode' => 'boolean'],
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
foreach ($tokens as $token) {
|
||
$tokenLike = '%'.addcslashes($token, '\\%_').'%';
|
||
$content->where(fn (Builder $inner) => $inner
|
||
->whereLike('title', $tokenLike)
|
||
->orWhereLike('subtitle', $tokenLike)
|
||
->orWhereLike('text', $tokenLike)
|
||
->orWhereLike('keywords', $tokenLike));
|
||
}
|
||
});
|
||
|
||
if ($companyIds->isNotEmpty()) {
|
||
$outer->orWhereIn('company_id', $companyIds);
|
||
}
|
||
|
||
if ($categoryIds->isNotEmpty()) {
|
||
$outer->orWhereIn('category_id', $categoryIds);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Beschränkt auf PMs, die dem User gehören (als Autor) oder zu einer ihm
|
||
* zugeordneten Firma. Bewusst ohne Portal-GlobalScope: ein Kunde darf
|
||
* seine eigenen PMs portalübergreifend sehen und bearbeiten.
|
||
*
|
||
* Single Source of Truth für den Kunden-Zugriff auf PMs — genutzt von der
|
||
* Übersicht, Detail-, Bearbeiten- und Nachweis-Ansicht.
|
||
*/
|
||
public function scopeAccessibleBy(Builder $query, User $user): Builder
|
||
{
|
||
// company_id IN (<Subquery>) statt eines materialisierten whereIn() mit
|
||
// zehntausenden IDs: MySQL loest das als Semi-Join ueber den company_id-
|
||
// Index (im Benchmark ~3x schneller als ein korreliertes orWhereHas-
|
||
// EXISTS). Company::accessibleBy() (Owner ODER Pivot-Mitglied) ist die
|
||
// Single Source of Truth; als Eloquent-Subquery werden die Company-
|
||
// Global-Scopes (PortalScope, SoftDeletes) mitkompiliert.
|
||
return $query->withoutGlobalScopes()
|
||
->where(function (Builder $q) use ($user): void {
|
||
$q->where('user_id', $user->id)
|
||
->orWhereIn('company_id', Company::accessibleBy($user)->select('companies.id'));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Nach Status gruppierte Anzahl der für $user zugänglichen PMs.
|
||
*
|
||
* Bewusst als UNION zweier indexfreundlicher Zweige (Autor über user_id,
|
||
* Firmenzugriff über den company_id-Semi-Join) statt des OR-Filters aus
|
||
* {@see scopeAccessibleBy()}: Der OR-Ausdruck zwingt MySQL bei Power-Usern
|
||
* zu Full-Scans (companies type=ALL); die UNION-Variante ist im Benchmark
|
||
* ~2,5x schneller bei identischem Ergebnis. Die UNION dedupliziert über die
|
||
* PK, sodass eine PM, die beide Zweige erfüllt, nur einmal zählt.
|
||
*
|
||
* @param (Closure(Builder<self>): mixed)|null $constrain zusätzliche
|
||
* Einschränkung, die auf beide Zweige angewandt wird (z. B. der
|
||
* Firmen-Filter der Übersicht)
|
||
* @return array<string, int> status-Wert => Anzahl
|
||
*/
|
||
public static function accessibleStatusCountsFor(User $user, ?Closure $constrain = null): array
|
||
{
|
||
$branch = function (Closure $where) use ($constrain): Builder {
|
||
$query = static::query()->withoutGlobalScopes()->select('id', 'status');
|
||
$where($query);
|
||
|
||
if ($constrain !== null) {
|
||
$constrain($query);
|
||
}
|
||
|
||
return $query;
|
||
};
|
||
|
||
$authored = $branch(fn (Builder $q) => $q->where('user_id', $user->id));
|
||
$viaCompany = $branch(fn (Builder $q) => $q->whereIn(
|
||
'company_id',
|
||
Company::accessibleBy($user)->select('companies.id'),
|
||
));
|
||
|
||
return DB::query()
|
||
->fromSub($authored->union($viaCompany), 'accessible_press_releases')
|
||
->selectRaw('status, COUNT(*) as aggregate')
|
||
->groupBy('status')
|
||
->pluck('aggregate', 'status')
|
||
->map(fn ($count): int => (int) $count)
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* Seitenweise, für $user zugängliche PMs – über dieselbe index-freundliche
|
||
* UNION wie {@see accessibleStatusCountsFor()} statt des OR-Filters aus
|
||
* {@see scopeAccessibleBy()}.
|
||
*
|
||
* Warum nicht `accessibleBy()->paginate()`? Der OR-Ausdruck entartet bei
|
||
* beiden Extremen: bei leeren/sparsamen Treffermengen scannt der Zeilen-
|
||
* Fetch den created_at-Index vollständig (~1 s für 0 Treffer), und der
|
||
* Paginator-`count(*)` macht ohnehin einen Full-Scan (~1 s, EXPLAIN
|
||
* type=ALL). Stattdessen: schmale UNION (nur Sortierspalten) liefert die
|
||
* Seiten-IDs über die user_id-/company_id-Indizes, danach werden nur die
|
||
* 25 Modelle der Seite hydratisiert. Ergebnis: ~100 ms für normale, <500 ms
|
||
* für die größten Konten (Benchmark 02.07.).
|
||
*
|
||
* @param (Closure(Builder<self>): mixed)|null $constrain Filter, die auf
|
||
* beide UNION-Zweige angewandt werden (Firmen-/Status-/Portal-/Suchfilter)
|
||
* @return LengthAwarePaginator<self>
|
||
*/
|
||
public static function accessiblePageFor(
|
||
User $user,
|
||
?Closure $constrain = null,
|
||
string $sortColumn = 'created_at',
|
||
string $sortDir = 'desc',
|
||
int $perPage = 25,
|
||
): LengthAwarePaginator {
|
||
$sortColumn = in_array($sortColumn, ['created_at', 'title', 'status'], true) ? $sortColumn : 'created_at';
|
||
$sortDir = $sortDir === 'asc' ? 'asc' : 'desc';
|
||
|
||
$branch = function (Closure $where) use ($constrain): Builder {
|
||
$query = static::query()->withoutGlobalScopes()
|
||
->select('id', 'created_at', 'title', 'status');
|
||
$where($query);
|
||
|
||
if ($constrain !== null) {
|
||
$constrain($query);
|
||
}
|
||
|
||
return $query;
|
||
};
|
||
|
||
$authored = $branch(fn (Builder $q) => $q->where('user_id', $user->id));
|
||
$viaCompany = $branch(fn (Builder $q) => $q->whereIn(
|
||
'company_id',
|
||
Company::accessibleBy($user)->select('companies.id'),
|
||
));
|
||
|
||
$accessible = DB::query()->fromSub($authored->union($viaCompany), 'accessible_press_releases');
|
||
|
||
$page = Paginator::resolveCurrentPage();
|
||
$total = (clone $accessible)->count();
|
||
|
||
$pageIds = $accessible
|
||
->orderBy($sortColumn, $sortDir)
|
||
->orderBy('id', $sortDir)
|
||
->forPage($page, $perPage)
|
||
->pluck('id');
|
||
|
||
$models = static::query()->withoutGlobalScopes()
|
||
->whereKey($pageIds)
|
||
// Kategorie fürs Listen-Rendering (localizedCategoryName, 13.07.);
|
||
// Bilder fürs Listen-Thumbnail — Titelbild zuerst (is_preview,
|
||
// gleiche Reihenfolge wie in der PM-Vorschau).
|
||
->with([
|
||
'company:id,name',
|
||
'category.translations',
|
||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
|
||
])
|
||
// Für das Einreichungs-Modal je Zeile: ohne eigenes Bild entfällt
|
||
// die Bildrechte-Bestätigung.
|
||
->withCount('images')
|
||
->orderBy($sortColumn, $sortDir)
|
||
->orderBy('id', $sortDir)
|
||
->get();
|
||
|
||
return new LengthAwarePaginator(
|
||
$models,
|
||
$total,
|
||
$perPage,
|
||
$page,
|
||
['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Bewusst enger als {@see scopeAccessibleBy()}: Der API-Zugriff meint nur
|
||
* die eigene Autorschaft (user_id), nicht den breiteren Firmenzugriff.
|
||
* Single Source of Truth für die API-Controller.
|
||
*/
|
||
public function scopeOwnedByApiUser(Builder $query, User $user): Builder
|
||
{
|
||
return $query->where('user_id', $user->id);
|
||
}
|
||
|
||
/**
|
||
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
|
||
* <p>/<br>-wrapped legacy plain text for older imports.
|
||
*/
|
||
public function renderedText(): HtmlString
|
||
{
|
||
return app(PressReleaseHtmlSanitizer::class)->render($this->text);
|
||
}
|
||
|
||
public function plainTextLength(): int
|
||
{
|
||
return app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
|
||
}
|
||
}
|