*/ use HasFactory, HasUniqueSlug, SoftDeletes; /** * @return list */ 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); } }); // Prüfsumme über den Inhalt (Anhang 2, 1c.7) — bei jeder Änderung an // Titel oder Text, und nachträglich für Zeilen ohne Summe. Ändert // sich die Summe an einer bestehenden Zeile, ist das die letzte // inhaltliche Änderung (content_changed_at, Umsetzungsaudit // 29.09.2026 R-02) — Grundlage für lastmod/dateModified. static::saving(function (self $pressRelease): void { if ($pressRelease->content_hash === null || $pressRelease->isDirty(['title', 'text'])) { $hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text); if ($pressRelease->exists && $pressRelease->isDirty(['title', 'text']) && $hash !== $pressRelease->getOriginal('content_hash')) { $pressRelease->content_changed_at = now(); } $pressRelease->content_hash = $hash; } }); } protected $fillable = [ 'uuid', 'portal', 'user_id', 'company_id', 'category_id', 'language', 'title', 'subtitle', 'slug', 'text', 'content_hash', 'content_changed_at', 'canonical_press_release_id', 'boilerplate_override', 'placeholder_variant', 'backlink_url', 'keywords', 'ai_text_declaration', 'ai_declaration_confirmed_at', 'status', 'classification', 'classified_at', 'auto_check_attempts', 'manual_review_required_at', 'content_score', 'content_tier', 'scored_at', 'hits', 'teaser_begin', 'teaser_end', 'no_export', 'published_at', 'depublished_at', 'published_ever_at', 'archive_pending_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', 'auto_check_attempts' => 'integer', 'manual_review_required_at' => 'datetime', 'content_score' => 'integer', 'content_tier' => PressReleaseContentTier::class, 'scored_at' => 'datetime', 'content_changed_at' => 'datetime', 'hits' => 'integer', 'teaser_begin' => 'integer', 'teaser_end' => 'integer', 'no_export' => 'boolean', 'published_at' => 'datetime', 'depublished_at' => 'datetime', 'published_ever_at' => 'datetime', 'archive_pending_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', ]; } /** * Letzte inhaltliche Änderung für lastmod, dateModified und * article:modified_time (Umsetzungsaudit 29.09.2026, R-02): bewusst nicht * `updated_at`, das auch Aufrufzähler und Firmen-Merge bewegt haben. * Ohne Änderung nach der Veröffentlichung gilt `published_at` — eine * Änderung am Entwurf vor der Veröffentlichung liegt davor und zählt nicht. */ public function contentModifiedAt(): ?CarbonInterface { $changedAt = $this->content_changed_at; $publishedAt = $this->published_at; if ($changedAt === null || ($publishedAt !== null && $changedAt->lessThanOrEqualTo($publishedAt))) { return $publishedAt; } return $changedAt; } /** * 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() ->withoutGlobalScope(PortalScope::class) ->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() ->withoutGlobalScope(PortalScope::class) ->where('company_id', $this->company_id) ->whereNotNull('email') ->where('email', '!=', '') ->orderBy('id') ->value('email'); if (is_string($companyContactEmail) && $companyContactEmail !== '') { return $companyContactEmail; } } $companyEmail = $this->company()->withoutGlobalScope(PortalScope::class)->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; } /** * Titelbild für Teaser und Listen: das erste öffentlich auslieferbare * Bild (Umsetzungsaudit 29.09.2026, B-01). Vorgeladene Bilder werden * gefiltert, auch wenn sie ungefiltert geladen wurden; ohne Vorladen * genau eine gefilterte Abfrage statt Lazy Loading aller Bilder. */ private function cardImage(): ?PressReleaseImage { $images = $this->relationLoaded('images') ? $this->images : $this->images()->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1)->get(); return $images->first(fn (PressReleaseImage $image): bool => $image->isPubliclyDeliverable()); } /** * Nachweiszeile des Titelbilds für Teaser (Bildkennzeichnung H.2): * „Foto: …" bzw. „Quelle: …" plus KI-Zusatz, null ohne Bild. */ public function cardImageCredit(): ?string { $image = $this->cardImage(); return $image ? ImageCredit::for($image, $this->company)->line() : null; } /** * Bild-URL für Kartenansichten: card-Variante des Titelbildes, * Fallback Original. Null ohne öffentlich auslieferbares Bild (dann * greift chosenPlaceholderUrl() bzw. der generische Fallback). */ public function cardImageUrl(): ?string { $image = $this->cardImage(); return $image?->variantUrl('card') ?? $image?->url(); } /** * Bild-URL für Listen-Thumbnails: thumb-Variante des Titelbildes, * Fallback card-Variante bzw. Original. Null ohne öffentlich * auslieferbares Bild (dann greift chosenPlaceholderUrl() bzw. der * generische Fallback). */ public function thumbImageUrl(): ?string { $image = $this->cardImage(); 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'); } /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * @return BelongsTo */ public function company(): BelongsTo { return $this->belongsTo(Company::class); } /** * @return BelongsTo */ public function category(): BelongsTo { return $this->belongsTo(Category::class); } /** * @return HasMany */ public function images(): HasMany { return $this->hasMany(PressReleaseImage::class); } /** * @return HasMany */ public function attachments(): HasMany { return $this->hasMany(PressReleaseAttachment::class); } /** * @return BelongsToMany */ public function contacts(): BelongsToMany { return $this->belongsToMany(Contact::class, 'press_release_contact'); } /** * @return HasMany */ public function statusLogs(): HasMany { return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); } /** * Ereignis-Log (Aufrufe/Shares) — Aufruf-Gesamtzähler bleibt `hits`. * * @return HasMany */ 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); } } /** * Maßgebliche Fassung bei Doppelveröffentlichung (Anhang 2, 1c.8): * gesetzt auf der zweiten Fassung, zeigt auf die erste. Scope-frei, weil * die Erstquelle auf dem anderen Portal liegt. * * @return BelongsTo */ public function canonical(): BelongsTo { return $this->belongsTo(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScope(PortalScope::class); } /** * Fassungen, die auf diese Meldung als Erstquelle verweisen. * * @return HasMany */ public function duplicates(): HasMany { return $this->hasMany(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScope(PortalScope::class); } public function isCanonicalDuplicate(): bool { return $this->canonical_press_release_id !== null; } /** * Crawler-Abruf-Protokoll (Roadmap 1c.0). * * @return HasMany */ public function crawlerHits(): HasMany { return $this->hasMany(PressReleaseCrawlerHit::class); } /** * Protokolliert den Abruf durch einen bekannten Such- oder KI-Dienst * (Roadmap 1c.0) — wie der Aufruf-Zähler nach der Response und * fehlertolerant: das Protokoll darf die Seite nie bremsen oder brechen. * Gespeichert werden Dienst, gekürzter User-Agent und Zeitpunkt, keine IP. */ public static function recordDeferredCrawlerHit(int $pressReleaseId, string $service, string $userAgent): void { try { PressReleaseCrawlerHit::create([ 'press_release_id' => $pressReleaseId, 'service' => $service, 'user_agent' => mb_substr($userAgent, 0, 255), ]); } catch (\Throwable $e) { report($e); } } /** * Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) — * jeder Submit erzeugt einen neuen, unveränderlichen Eintrag. * * @return HasMany */ 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 */ public function publicPortalLinks(): array { if (! $this->slug) { return []; } $portals = match ($this->portal) { Portal::Both => [Portal::Presseecho, Portal::Businessportal24], 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); } /** * @return HasMany */ public function kiAudits(): HasMany { return $this->hasMany(KiAudit::class)->orderByDesc('created_at'); } /** * @return HasMany */ 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; } /** * War die Meldung (möglicherweise) je öffentlich? Maßgeblich für alles, * was „nie veröffentlicht" sofort löscht (Umsetzungsaudit 29.09.2026, * A-05): `published_ever_at` — und im Altbestand `rejected`, weil das * Altsystem damit auch „offline genommen" abbildete; der Backfill kann * das nicht unterscheiden (Dev-Kopie: 126.383 solche Meldungen ohne * `published_ever_at`). */ public function wasEverPublished(): bool { return $this->published_ever_at !== null || ($this->legacy_id !== null && $this->status === PressReleaseStatus::Rejected); } /** * Gegenstück zu wasEverPublished() für Abfragen. */ public function scopeNeverPublished(Builder $query): Builder { return $query->whereNull($query->qualifyColumn('published_ever_at')) ->where(fn (Builder $q) => $q->whereNull($q->qualifyColumn('legacy_id')) ->orWhere($q->qualifyColumn('status'), '!=', PressReleaseStatus::Rejected->value)); } /** * Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein * Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue. */ public function requiresManualReview(): bool { return $this->manual_review_required_at !== null; } /** * Wartet geblockt auf die menschliche Entscheidung. */ public function isAwaitingManualReview(): bool { return $this->status === PressReleaseStatus::Review && $this->requiresManualReview(); } /** * 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 $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 () 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->withoutGlobalScope(PortalScope::class) ->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): mixed)|null $constrain zusätzliche * Einschränkung, die auf beide Zweige angewandt wird (z. B. der * Firmen-Filter der Übersicht) * @return array status-Wert => Anzahl */ public static function accessibleStatusCountsFor(User $user, ?Closure $constrain = null): array { $branch = function (Closure $where) use ($constrain): Builder { $query = static::query()->withoutGlobalScope(PortalScope::class)->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): mixed)|null $constrain Filter, die auf * beide UNION-Zweige angewandt werden (Firmen-/Status-/Portal-/Suchfilter) * @return LengthAwarePaginator */ 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()->withoutGlobalScope(PortalScope::class) ->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()->withoutGlobalScope(PortalScope::class) ->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 *

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