newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a3e0042055
commit
0c1c072487
15 changed files with 665 additions and 37 deletions
53
app/Services/Company/MergedNewsroomLookup.php
Normal file
53
app/Services/Company/MergedNewsroomLookup.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyMergeMove;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Wohin ein Newsroom-Slug führt, dessen Firma in einer Zusammenführung
|
||||
* (Roadmap 1a.3) aufgegangen ist: zum Überlebenden. Gebraucht von der
|
||||
* öffentlichen Newsroom-Seite (301) und vom Verwaltungszugang (1b.2) — beide
|
||||
* müssen dieselbe Antwort geben.
|
||||
*/
|
||||
class MergedNewsroomLookup
|
||||
{
|
||||
/**
|
||||
* Der Überlebende zu einem aufgegangenen Slug, oder null, wenn der Slug
|
||||
* nie aufgegangen ist oder die Zusammenführung rückabgewickelt wurde.
|
||||
*
|
||||
* @param list<string> $portalValues
|
||||
*/
|
||||
public function survivorFor(array $portalValues, string $slug): ?Company
|
||||
{
|
||||
$mergedIds = Company::onlyTrashed()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('slug', $slug)
|
||||
->pluck('id');
|
||||
|
||||
if ($mergedIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$survivorId = CompanyMergeMove::query()
|
||||
->where('subject', CompanyMergeSubject::Company->value)
|
||||
->whereIn('subject_id', $mergedIds)
|
||||
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
||||
->with('merge:id,survivor_company_id')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->merge
|
||||
?->survivor_company_id;
|
||||
|
||||
if ($survivorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Company::query()->withoutGlobalScope(PortalScope::class)->find($survivorId);
|
||||
}
|
||||
}
|
||||
107
app/Services/Company/NewsroomClaimService.php
Normal file
107
app/Services/Company/NewsroomClaimService.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\ConversionSource;
|
||||
use App\Models\Company;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang zu einer
|
||||
* Firma über die hinterlegte Firmen-E-Mail, unabhängig davon, ob der
|
||||
* Newsroom öffentlich ist.
|
||||
*
|
||||
* Der Unterschied zur allgemeinen Anmeldung per E-Mail-Link: Der Besucher
|
||||
* gibt KEINE Adresse ein. Der Link geht an das, was an der Firma hinterlegt
|
||||
* ist — die Firmen-E-Mail, sonst die Pressekontakte. Wer das Postfach hat,
|
||||
* verwaltet die Firma; wer es nicht hat, erfährt nur eine maskierte Adresse.
|
||||
* Kein neues Zugangssystem: Konto, Zuordnung und Login laufen durch
|
||||
* `ContactAccessService`, genau wie bei der Anmeldung über pressekonto.
|
||||
*
|
||||
* Die Herkunft `profile_claim` steht auf dem Token — so misst die
|
||||
* Konversionsleiter, wie viele Newsrooms über diesen Weg beansprucht werden.
|
||||
*/
|
||||
class NewsroomClaimService
|
||||
{
|
||||
public function __construct(private readonly ContactAccessService $contactAccess) {}
|
||||
|
||||
/**
|
||||
* Firma zu einem Slug auf diesen Portalen — auch ohne veröffentlichte
|
||||
* Meldung, auch deaktiviert. Nicht aber 410-entfernt oder gelöscht: Die
|
||||
* Firma existiert als Datensatz, aber nicht mehr als Newsroom.
|
||||
*
|
||||
* @param list<string> $portalValues
|
||||
*/
|
||||
public function findClaimable(array $portalValues, string $slug): ?Company
|
||||
{
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('slug', $slug)
|
||||
->whereNull('gone_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empfänger des Anmeldelinks: die Firmen-E-Mail, sonst die Adressen der
|
||||
* Pressekontakte. Bewusst nicht beides — ein Klick eines Fremden soll
|
||||
* nicht fünf Postfächer treffen.
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public function recipients(Company $company): Collection
|
||||
{
|
||||
$companyEmail = Company::emailKey($company->email);
|
||||
|
||||
if ($companyEmail !== null) {
|
||||
return collect([$companyEmail]);
|
||||
}
|
||||
|
||||
$emails = [];
|
||||
|
||||
foreach ($company->contacts()->withoutGlobalScope(PortalScope::class)->pluck('email') as $email) {
|
||||
$key = Company::emailKey($email);
|
||||
|
||||
if ($key !== null) {
|
||||
$emails[$key] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return collect(array_values($emails));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen
|
||||
* tatsächlich etwas ging — deaktivierte Konten bekommen nichts.
|
||||
*/
|
||||
public function request(Company $company, ?string $ip): int
|
||||
{
|
||||
$sent = 0;
|
||||
|
||||
foreach ($this->recipients($company) as $email) {
|
||||
if ($this->contactAccess->requestMagicAccess($email, $ip, ConversionSource::ProfileClaim, $company->getKey())) {
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
public function maskedRecipients(Company $company): Collection
|
||||
{
|
||||
return $this->recipients($company)->map(fn (string $email): string => self::mask($email));
|
||||
}
|
||||
|
||||
public static function mask(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
|
||||
return Str::substr($local, 0, 1).'***@'.$domain;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue