presseportale/app/Models/Company.php
Kevin Adametz 2c332052ce web: Newsroom-Seite mit eigenem Kasten für den Verwaltungszugang
Statt einer grauen Textzeile in der Profilbox derselbe Kasten wie unter jeder
Meldung (maskierte Adresse, Knopf „Anmeldelink anfordern“), jetzt als
gemeinsame Komponente. Mobil direkt unter dem Profil, am Desktop oben in der
Seitenleiste; der doppelte Kasten „Eigenen Newsroom starten“ entfällt.
Roadmap 6.17: 7 Firmen öffneten die Verwaltungsseite, eine forderte an.

Changelog: Verbessert
Public: Auf jeder Newsroom-Seite führt ein eigener Kasten zum kostenlosen Verwaltungszugang – mit der Adresse, an die der Anmeldelink geht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:26:08 +00:00

347 lines
11 KiB
PHP

<?php
namespace App\Models;
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Models\Concerns\HasUniqueSlug;
use App\Scopes\PortalScope;
use Database\Factories\CompanyFactory;
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\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Company extends Model
{
/** @use HasFactory<CompanyFactory> */
use HasFactory, HasUniqueSlug, SoftDeletes;
/**
* @return list<string>
*/
protected function slugScopeAttributes(): array
{
return ['portal'];
}
protected function slugFallback(): string
{
return 'company';
}
/**
* Portalübergreifende Firmen (`both`, seit der Zusammenführung 1a.3)
* sind auf JEDEM Portal erreichbar — die öffentliche Auflösung fragt
* `portal IN (eigenes, both)`. Ein Slug ist deshalb nur frei, wenn ihn
* weder eine Firma desselben Portals noch eine `both`-Firma trägt; und
* eine `both`-Firma braucht ihn auf allen Portalen frei. Der reine
* Portal-Scope des Traits sähe die Überschneidung nicht.
*
* @param array<string, mixed> $scope
*/
protected function slugExists(string $slug, array $scope): bool
{
$portal = array_key_exists('portal', $scope) ? $scope['portal'] : $this->getAttribute('portal');
$portal = $portal instanceof Portal ? $portal->value : $portal;
$query = static::query()
->withoutGlobalScopes()
->where($this->slugColumn(), $slug);
if ($this->exists) {
$query->whereKeyNot($this->getKey());
}
if ($portal !== null && $portal !== Portal::Both->value) {
$query->whereIn('portal', [$portal, Portal::Both->value]);
}
return $query->exists();
}
protected static function booted(): void
{
static::addGlobalScope(new PortalScope);
}
protected $fillable = [
'portal',
'owner_user_id',
'type',
'name',
'slug',
'address',
'country_code',
'phone',
'fax',
'email',
'website',
'logo_path',
'logo_variants',
'boilerplate',
'is_active',
'gone_at',
'is_active_before_gone',
'disable_footer_code',
'legacy_portal',
'legacy_id',
];
protected function casts(): array
{
return [
'portal' => Portal::class,
'type' => CompanyType::class,
'logo_variants' => 'array',
'is_active' => 'boolean',
'gone_at' => 'datetime',
'is_active_before_gone' => 'boolean',
'disable_footer_code' => 'boolean',
'deleted_at' => 'datetime',
];
}
/**
* Firmensuche für die Admin-Combobox: Volltext (name/email/slug) bei
* unterstützenden Treibern und Suchbegriffen ab 3 Zeichen, sonst LIKE.
* Bei leerem Begriff bleibt die Query unverändert. Single Source of Truth
* für die zuvor in mehreren Editoren duplizierte Such- und Treiberlogik.
*/
public function scopeSearchByTerm(Builder $query, string $term): Builder
{
$term = Portal::stripTrailingAbbreviation($term);
if (! filled($term)) {
return $query;
}
$supportsFullText = mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
return $query->where(function (Builder $query) use ($term, $supportsFullText): void {
if ($supportsFullText) {
$query->whereFullText(['name', 'email', 'slug'], $term);
return;
}
$query->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
});
}
/**
* Firmen, auf die ein User zugreifen darf: als Owner (owner_user_id) oder
* als Mitglied über den company_user-Pivot. Single Source of Truth für die
* zuvor in mehreren API-Controllern und Services duplizierte Closure. Das
* Scope-Verhalten (mit/ohne globale Scopes) bestimmt der Aufrufer.
*/
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
return $query->where(function (Builder $query) use ($user): void {
$query->where('owner_user_id', $user->id)
->orWhereHas('users', fn (Builder $users) => $users->whereKey($user->id));
});
}
/**
* Mit 410 entfernter Newsroom: die Newsroom-URL bleibt bestehen und liefert
* einen Tombstone statt eines 404. Gegenstück zur vollständigen Löschung
* (Soft Delete).
*/
public function isGone(): bool
{
return $this->gone_at !== null;
}
/**
* Öffentliche Newsroom-Seiten dieser Firma, je Portal — nur dort, wo sie
* schon erscheint: Der Newsroom ist öffentlich ab der ersten sichtbaren
* Meldung in dieser Sprache (Roadmap 1b.1). Deutsch hat Vorrang, sonst
* Englisch. Für die Verknüpfung „Firma ↔ Newsroom“ im Kunden- und
* Adminbereich.
*
* @return list<array{portal: Portal, label: string, url: string}>
*/
public function publicNewsroomLinks(): array
{
if (! $this->slug || ! $this->is_active || $this->isGone()) {
return [];
}
$portals = $this->portal === Portal::Both ? [Portal::Presseecho, Portal::Businessportal24] : [$this->portal];
$links = [];
foreach ($portals as $portal) {
$languages = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('company_id', $this->getKey())
->whereIn('portal', [$portal->value, Portal::Both->value])
->visibleInArchive()
->whereIn('language', ['de', 'en'])
->distinct()
->pluck('language')
->all();
if ($languages === []) {
continue;
}
$links[] = [
'portal' => $portal,
'label' => $portal->label(),
'url' => sprintf(
'%s/%s/newsroom/%s',
rtrim((string) ($portal === Portal::Presseecho ? config('domains.domain_presseecho_url') : config('domains.domain_businessportal_url')), '/'),
in_array('de', $languages, true) ? 'de' : 'en',
$this->slug,
),
];
}
return $links;
}
/**
* Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug?
* Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung
* fragt `portal IN (eigenes, both)` und fände zwei Firmen.
*
* Die anderen Portale werden ausdrücklich aufgezählt statt `portal != ?`
* — nur so greift der Unique-Index (portal, slug).
*
* @param list<int> $exceptIds Firmen, die nicht zählen (z. B. die eigene Adressgruppe)
*/
public function slugTakenOnOtherPortals(array $exceptIds = []): bool
{
$otherPortals = array_values(array_diff(
array_map(fn (Portal $portal): string => $portal->value, Portal::cases()),
[$this->portal->value],
));
return static::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $otherPortals)
->where('slug', $this->slug)
->whereKeyNot([$this->getKey(), ...$exceptIds])
->exists();
}
/**
* Normalisierte Firmen-E-Mail — der Schlüssel der Newsroom-Konsolidierung.
*
* Die eine Stelle, die festlegt, was „dieselbe Adresse" heißt.
* Klassifizierer, Bericht, Zusammenführung und laufende Zuordnung müssen
* exakt gleich normalisieren, sonst finden sie einander nicht.
*/
public static function emailKey(?string $email): ?string
{
$key = mb_strtolower(trim((string) $email));
return $key === '' ? null : $key;
}
/**
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role', 'granted_via')
->withTimestamps();
}
/**
* @return HasMany<Contact, $this>
*/
public function contacts(): HasMany
{
return $this->hasMany(Contact::class);
}
/**
* @return HasMany<PressRelease, $this>
*/
public function pressReleases(): HasMany
{
return $this->hasMany(PressRelease::class);
}
/**
* @return BelongsToMany<UserPaymentOption, $this>
*/
public function userPaymentOptions(): BelongsToMany
{
return $this->belongsToMany(UserPaymentOption::class, 'user_payment_option_company')
->withPivot('is_active')
->withTimestamps();
}
public function logoUrl(): ?string
{
if (blank($this->logo_path)) {
return null;
}
$logoPath = trim((string) $this->logo_path);
$legacyPortal = $this->legacy_portal ?: $this->portal?->value;
$logoFilename = basename((string) (parse_url($logoPath, PHP_URL_PATH) ?: $logoPath));
$candidates = [];
if (Str::startsWith($logoPath, '/storage/')) {
$candidates[] = Str::after($logoPath, '/storage/');
} elseif (! Str::startsWith($logoPath, ['http://', 'https://'])) {
$candidates[] = ltrim($logoPath, '/');
}
if (filled($logoFilename)) {
$candidates[] = "company-logos/{$legacyPortal}/{$this->id}/{$logoFilename}";
$candidates[] = "{$legacyPortal}/company/{$logoFilename}";
}
foreach (array_unique($candidates) as $candidate) {
if (Storage::disk('public')->exists($candidate)) {
return asset('storage/'.$candidate);
}
}
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($this->legacy_portal)) {
return $logoPath;
}
return null;
}
/**
* Absolute Website-URL inklusive Schema. Bestandsdaten enthalten oft
* Adressen wie „www.example.de“ — als roher href würde der Browser
* daraus einen relativen Link auf der aktuellen Seite machen.
*/
public function websiteUrl(): ?string
{
if (blank($this->website)) {
return null;
}
$website = trim((string) $this->website);
return Str::startsWith($website, ['http://', 'https://'])
? $website
: 'https://'.$website;
}
}