*/ 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)->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', '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', '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 { static::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressReleaseId) ->increment('hits'); PressReleaseEvent::create([ 'press_release_id' => $pressReleaseId, 'type' => PressReleaseEventType::View, ]); } /** * 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 */ 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()); } /** * 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()); } /** * @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->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): 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()->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): 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()->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 *

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