presseportale/app/Models/PressRelease.php
Kevin Adametz 198be381d6 T12: Depublikations-Checkout mit Tombstone (config-gated)
Kunden koennen eigene veroeffentlichte PMs kostenpflichtig aus der
oeffentlichen Ansicht nehmen: Panel auf der PM-Detailseite (inkl.
T1neu-Unternehmer-Checkbox und Doppelausweis netto/brutto), Stripe-
Ad-hoc-Checkout wie Credit-Topups, Erfuellung im Webhook — die PM
wechselt auf archived + depublished_at, die oeffentliche URL zeigt
dauerhaft einen Tombstone (410 Gone, ohne Inhalte, mit ehrlichem
Drittarchive-Hinweis). Doppelbuchungs-Bremse pro PM; Status-Log mit
Quelle "depublication". Gate: ohne BILLING_DEPUBLICATION_PRICE_CENTS
bleibt der Weg deaktiviert und /melden zeigt die neutrale
Preisformulierung [EXTERN: Preis festlegen, O6].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:46:25 +00:00

392 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Concerns\HasUniqueSlug;
use App\Scopes\PortalScope;
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;
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',
'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,
'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');
}
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<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);
}
/**
* 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)
->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
* <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);
}
}