*/ 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', '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; } /** * Sperrfrist (Embargo) in der Anwendungs-Zeitzone (Europe/Berlin). */ public function embargoAtLocal(): ?Carbon { return $this->embargo_at; } 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'); } /** * 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. */ public function scopeBoosted(Builder $query): Builder { return $query->whereHas('boosts', fn (Builder $q) => $q->active()); } /** * 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()); } /** * @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) { $content->whereFullText( ['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) ->with('company:id,name') ->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); } }