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
|
|
@ -7,13 +7,17 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Listeners\RecordAuthActivity;
|
use App\Listeners\RecordAuthActivity;
|
||||||
use App\Models\MagicLink;
|
use App\Models\MagicLink;
|
||||||
use App\Services\Conversion\ConversionRecorder;
|
use App\Services\Conversion\ConversionRecorder;
|
||||||
|
use App\Services\Customer\CustomerCompanyContext;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class MagicLinkConsumeController extends Controller
|
class MagicLinkConsumeController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ConversionRecorder $recorder) {}
|
public function __construct(
|
||||||
|
private readonly ConversionRecorder $recorder,
|
||||||
|
private readonly CustomerCompanyContext $companies,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, string $token): RedirectResponse
|
public function __invoke(Request $request, string $token): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|
@ -95,6 +99,15 @@ class MagicLinkConsumeController extends Controller
|
||||||
? route('me.dashboard', absolute: false)
|
? route('me.dashboard', absolute: false)
|
||||||
: '/');
|
: '/');
|
||||||
|
|
||||||
|
// „Diesen Newsroom verwalten" (Roadmap 1b.2): direkt auf die Firma,
|
||||||
|
// um die es ging — sofern der Account sie wirklich verwalten darf.
|
||||||
|
$landingCompanyId = (int) ($magicLink->payload['company_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin()
|
||||||
|
&& $this->companies->findFor($user, $landingCompanyId) !== null) {
|
||||||
|
$home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect($home);
|
return redirect($home);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
app/Http/Controllers/Web/NewsroomClaimController.php
Normal file
126
app/Http/Controllers/Web/NewsroomClaimController.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\Company\MergedNewsroomLookup;
|
||||||
|
use App\Services\Company\NewsroomClaimService;
|
||||||
|
use App\Support\WebPortalContext;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Seite „Diesen Newsroom verwalten" (Roadmap 1b.2). Erreichbar
|
||||||
|
* für jede Firma mit Slug — auch ohne veröffentlichte Meldung, denn der
|
||||||
|
* Verwaltungszugang ist von der öffentlichen Sichtbarkeit unabhängig. Nur
|
||||||
|
* 410-entfernte Newsrooms bleiben Tombstone, aufgegangene führen zum
|
||||||
|
* Überlebenden.
|
||||||
|
*/
|
||||||
|
class NewsroomClaimController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NewsroomClaimService $claims,
|
||||||
|
private readonly MergedNewsroomLookup $mergedNewsrooms,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $edition, string $slug): View|Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$context = WebPortalContext::fromRequest();
|
||||||
|
$company = $this->claims->findClaimable($context->portalValues, $slug);
|
||||||
|
|
||||||
|
if (! $company) {
|
||||||
|
return $this->resolveMissing($context, $edition, $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('web.newsroom-claim', [
|
||||||
|
'company' => $company,
|
||||||
|
'recipients' => $this->claims->maskedRecipients($company),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, string $edition, string $slug): RedirectResponse|Response
|
||||||
|
{
|
||||||
|
$context = WebPortalContext::fromRequest();
|
||||||
|
$company = $this->claims->findClaimable($context->portalValues, $slug);
|
||||||
|
|
||||||
|
if (! $company) {
|
||||||
|
return $this->resolveMissing($context, $edition, $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot: neutral abnicken, bewusst vor allem anderen.
|
||||||
|
if (filled($request->input('website'))) {
|
||||||
|
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureNotRateLimited($request, $company->getKey());
|
||||||
|
RateLimiter::hit($this->ipKey($request), 3600);
|
||||||
|
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
|
||||||
|
|
||||||
|
if ($this->claims->recipients($company)->isEmpty()) {
|
||||||
|
return back()->with('newsroom-claim-none', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
|
||||||
|
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
|
||||||
|
$this->claims->request($company, $request->ip());
|
||||||
|
|
||||||
|
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);
|
||||||
|
|
||||||
|
if ($survivor !== null && $survivor->gone_at === null) {
|
||||||
|
// Host aus dem Request wie bei den anderen 301-Weiterleitungen —
|
||||||
|
// route() allein trüge den konfigurierten Root-Host.
|
||||||
|
return redirect(
|
||||||
|
request()->getSchemeAndHttpHost().route('newsroom.claim', ['edition' => $edition, 'slug' => $survivor->slug], absolute: false),
|
||||||
|
301,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 410-entfernt → Tombstone wie die Newsroom-Seite selbst; sonst 404.
|
||||||
|
$gone = Company::query()
|
||||||
|
->withoutGlobalScope(PortalScope::class)
|
||||||
|
->whereIn('portal', $context->portalValues)
|
||||||
|
->where('slug', $slug)
|
||||||
|
->whereNotNull('gone_at')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($gone) {
|
||||||
|
return response()->view('web.newsroom-tombstone', [], 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureNotRateLimited(Request $request, int $companyId): void
|
||||||
|
{
|
||||||
|
$perDay = (int) config('newsroom.claim.links_per_day', 3);
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($this->companyKey($companyId), $perDay)) {
|
||||||
|
throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_company')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($this->ipKey($request), 15)) {
|
||||||
|
throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_ip')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function companyKey(int $companyId): string
|
||||||
|
{
|
||||||
|
return 'newsroom-claim|company|'.$companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipKey(Request $request): string
|
||||||
|
{
|
||||||
|
return 'newsroom-claim|ip|'.$request->ip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\Auth;
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
use App\Mail\MagicLoginLink;
|
use App\Mail\MagicLoginLink;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
|
|
@ -41,7 +42,7 @@ class ContactAccessService
|
||||||
* - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet.
|
* - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet.
|
||||||
* - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion.
|
* - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion.
|
||||||
*/
|
*/
|
||||||
public function requestMagicAccess(string $email, ?string $ip = null): bool
|
public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null): bool
|
||||||
{
|
{
|
||||||
$email = mb_strtolower(trim($email));
|
$email = mb_strtolower(trim($email));
|
||||||
$contacts = $this->contactsFor($email);
|
$contacts = $this->contactsFor($email);
|
||||||
|
|
@ -60,7 +61,7 @@ class ContactAccessService
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
|
||||||
$this->sendLoginLink($user, $ip);
|
$this->sendLoginLink($user, $ip, $source, $landingCompanyId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -199,9 +200,14 @@ class ContactAccessService
|
||||||
/**
|
/**
|
||||||
* Versendet einen regulären Login-Magic-Link an den Account.
|
* Versendet einen regulären Login-Magic-Link an den Account.
|
||||||
*/
|
*/
|
||||||
private function sendLoginLink(User $user, ?string $ip): void
|
private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null): void
|
||||||
{
|
{
|
||||||
$generated = $this->magicLinks->createLoginLink($user, $ip);
|
$generated = $this->magicLinks->createLoginLink(
|
||||||
|
$user,
|
||||||
|
$ip,
|
||||||
|
source: $source,
|
||||||
|
payload: $landingCompanyId !== null ? ['company_id' => $landingCompanyId] : [],
|
||||||
|
);
|
||||||
|
|
||||||
Mail::to($user->email)->queue((new MagicLoginLink(
|
Mail::to($user->email)->queue((new MagicLoginLink(
|
||||||
user: $user,
|
user: $user,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Illuminate\Support\Str;
|
||||||
class MagicLinkGenerator
|
class MagicLinkGenerator
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* @param array<string, mixed> $payload z. B. `company_id`, auf dessen Firmenseite der Login landen soll (Roadmap 1b.2)
|
||||||
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
|
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
|
||||||
*/
|
*/
|
||||||
public function createLoginLink(
|
public function createLoginLink(
|
||||||
|
|
@ -19,6 +20,7 @@ class MagicLinkGenerator
|
||||||
?string $requestedIp = null,
|
?string $requestedIp = null,
|
||||||
int $ttlMinutes = 15,
|
int $ttlMinutes = 15,
|
||||||
?ConversionSource $source = null,
|
?ConversionSource $source = null,
|
||||||
|
array $payload = [],
|
||||||
): array {
|
): array {
|
||||||
$plainToken = Str::random(64);
|
$plainToken = Str::random(64);
|
||||||
$tokenHash = hash('sha256', $plainToken);
|
$tokenHash = hash('sha256', $plainToken);
|
||||||
|
|
@ -40,6 +42,7 @@ class MagicLinkGenerator
|
||||||
// führt über zwei Domains und einen Mailclient, die Sitzung reißt
|
// führt über zwei Domains und einen Mailclient, die Sitzung reißt
|
||||||
// dabei genau an der interessanten Stelle (Roadmap 0.4).
|
// dabei genau an der interessanten Stelle (Roadmap 0.4).
|
||||||
'source' => $source?->value,
|
'source' => $source?->value,
|
||||||
|
'payload' => $payload ?: null,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => $expiresAt,
|
||||||
'ip_requested' => $requestedIp,
|
'ip_requested' => $requestedIp,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
namespace App\Services\Web;
|
namespace App\Services\Web;
|
||||||
|
|
||||||
use App\Enums\CompanyMergeSubject;
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\CompanyMergeMove;
|
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\Company\MergedNewsroomLookup;
|
||||||
use App\Support\WebPageCache;
|
use App\Support\WebPageCache;
|
||||||
use App\Support\WebPortalContext;
|
use App\Support\WebPortalContext;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
@ -22,6 +21,8 @@ use Illuminate\Support\Carbon;
|
||||||
*/
|
*/
|
||||||
class NewsroomDetailPageData
|
class NewsroomDetailPageData
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
@ -80,37 +81,13 @@ class NewsroomDetailPageData
|
||||||
*/
|
*/
|
||||||
private function mergedInto(array $portalValues, string $slug): ?string
|
private function mergedInto(array $portalValues, string $slug): ?string
|
||||||
{
|
{
|
||||||
$mergedIds = Company::onlyTrashed()
|
$survivor = $this->mergedNewsrooms->survivorFor($portalValues, $slug);
|
||||||
->withoutGlobalScope(PortalScope::class)
|
|
||||||
->whereIn('portal', $portalValues)
|
|
||||||
->where('slug', $slug)
|
|
||||||
->pluck('id');
|
|
||||||
|
|
||||||
if ($mergedIds->isEmpty()) {
|
if ($survivor === null || ! $survivor->is_active) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$survivorId = CompanyMergeMove::query()
|
return $survivor->slug !== $slug ? $survivor->slug : null;
|
||||||
->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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$target = Company::query()
|
|
||||||
->withoutGlobalScope(PortalScope::class)
|
|
||||||
->whereKey($survivorId)
|
|
||||||
->where('is_active', true)
|
|
||||||
->value('slug');
|
|
||||||
|
|
||||||
return $target !== null && $target !== $slug ? $target : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,19 @@ return [
|
||||||
'collection_threshold' => (int) env('NEWSROOM_MERGE_COLLECTION_THRESHOLD', 3),
|
'collection_threshold' => (int) env('NEWSROOM_MERGE_COLLECTION_THRESHOLD', 3),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| claim_links_per_day: Anmeldelinks je Newsroom und Tag. Der Versand geht
|
||||||
|
| an die hinterlegte Firmen-E-Mail, nicht an eine vom Besucher eingegebene
|
||||||
|
| — er darf trotzdem kein Werkzeug werden, fremde Postfächer zu fluten.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'claim' => [
|
||||||
|
'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 3),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,11 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
|
||||||
|
|
||||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1b.1 | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz | S | keine |
|
| ~~1b.1~~ | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz — **bestand; 15.09.2026 Rückbau im Cache vervollständigt** | S | keine |
|
||||||
| 1b.2 | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit | M | 1a.4 |
|
| ~~1b.2~~ | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit — **gebaut 15.09.2026**: `/newsroom/{slug}/verwalten`, Link an die hinterlegte Firmen-E-Mail (maskiert), Herkunft `profile_claim`, Landung auf der Firmenseite; noch unverlinkt (→ 1b.4) | M | 1a.4 |
|
||||||
| 1b.3 | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte | M | 1b.2 |
|
| 1b.3 | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte | M | 1b.2 |
|
||||||
| 1b.4 | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail | S | 1b.2 |
|
| 1b.4 | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail | S | 1b.2 |
|
||||||
| 1b.5 | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit | S | 1b.1 |
|
| ~~1b.5~~ | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit — **15.09.2026**: alle Admin-Wege (archivieren, Status ändern, löschen) leeren jetzt den Seiten-Cache | S | 1b.1 |
|
||||||
| 1b.6 | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen | S | Bounce-Suppression live |
|
| 1b.6 | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen | S | Bounce-Suppression live |
|
||||||
|
|
||||||
**Leitplanken Profilpflege**
|
**Leitplanken Profilpflege**
|
||||||
|
|
|
||||||
|
|
@ -895,3 +895,63 @@ Tests: `tests/Feature/Api/ApiNewsroomAssignmentTest.php` (9).
|
||||||
und Namensvariante müssen bei den 7 aus den Slug-Kollisionen bleiben. Steigt
|
und Namensvariante müssen bei den 7 aus den Slug-Kollisionen bleiben. Steigt
|
||||||
die Zahl, trifft die Zuordnung nicht; `companies:merges` und der
|
die Zahl, trifft die Zuordnung nicht; `companies:merges` und der
|
||||||
Aktivitätsverlauf zeigen, was zugeordnet wurde.
|
Aktivitätsverlauf zeigen, was zugeordnet wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Teil 4 — Block 1b: Newsroom als Produkt (15.09.2026)
|
||||||
|
|
||||||
|
## 1b.1 / 1b.5 — Sichtbarkeitsregel und Rückbau
|
||||||
|
|
||||||
|
Die Regel bestand bereits: Detailseite 404 ohne veröffentlichte Meldung,
|
||||||
|
Verzeichnis, Sitemap und Hub-Zähler nur mit einer. Der Rückbau war aber
|
||||||
|
unvollständig: `depublish()` und `markGone()` leerten den Seiten-Cache,
|
||||||
|
`archive()`, `changeStatusFromAdmin()` (weg von published) und
|
||||||
|
`deleteFromAdmin()` einer veröffentlichten Meldung nicht — der Newsroom stand
|
||||||
|
nach der letzten Meldung bis zu eine Stunde weiter im Cache. Jetzt leeren alle
|
||||||
|
fünf Wege. Drei Tests in `NewsroomPageTest` halten den Rückbau fest.
|
||||||
|
|
||||||
|
## 1b.2 — Verwaltungszugang über Magic Link
|
||||||
|
|
||||||
|
```
|
||||||
|
GET/POST /{edition}/newsroom/{slug}/verwalten
|
||||||
|
```
|
||||||
|
|
||||||
|
Der allgemeine Anmeldelink (`/anmeldelink` auf pressekonto, WS-2) bestand:
|
||||||
|
Wer eine E-Mail eingibt, die an einer Firma oder einem Pressekontakt steht,
|
||||||
|
bekommt lazy ein Konto mit Firmen-Scope. Was fehlte, war der Weg **vom
|
||||||
|
Newsroom aus**, ohne dass der Besucher eine Adresse kennt oder eingibt —
|
||||||
|
und unabhängig davon, ob der Newsroom überhaupt schon öffentlich ist.
|
||||||
|
|
||||||
|
**`NewsroomClaimService` + `NewsroomClaimController`:**
|
||||||
|
|
||||||
|
- Die Seite zeigt Firmenname und die **maskierte** Empfängeradresse
|
||||||
|
(`i***@muster.de`). Ein Klick schickt den Anmeldelink dorthin. Kein
|
||||||
|
Eingabefeld → kein Enumerationsproblem, keine fremde Adresse.
|
||||||
|
- Empfänger: die Firmen-E-Mail; fehlt sie, die Pressekontakte. Bewusst nicht
|
||||||
|
beides — ein Klick eines Fremden soll nicht fünf Postfächer treffen.
|
||||||
|
- Erreichbar für jede Firma mit Slug, **auch ohne veröffentlichte Meldung
|
||||||
|
und auch deaktiviert** (das ist der Punkt von 1b.2). 410-entfernte bleiben
|
||||||
|
Tombstone, aufgegangene (1a.3) führen per 301 zum Überlebenden — dieselbe
|
||||||
|
Auflösung wie die Newsroom-Seite, jetzt in `MergedNewsroomLookup`.
|
||||||
|
- Konto, Zuordnung (`responsible`) und Versand laufen durch
|
||||||
|
`ContactAccessService::requestMagicAccess()` — kein zweites Zugangssystem.
|
||||||
|
Neu dort: Herkunft und Ziel-Firma werden durchgereicht.
|
||||||
|
- **Herkunft `profile_claim` auf dem Token** — Stufe 2 der Konversionsleiter
|
||||||
|
misst damit, wie viele Newsrooms über diesen Weg beansprucht werden.
|
||||||
|
- **Landung:** Der Login führt direkt auf die Firmenseite im Kundenbereich
|
||||||
|
(`me.press-kits.show`), nicht aufs Dashboard — `company_id` liegt im
|
||||||
|
`payload` des Tokens, kein Schema-Eingriff.
|
||||||
|
- Drosselung: 3 Links je Newsroom und Tag (`config/newsroom.php`,
|
||||||
|
`NEWSROOM_CLAIM_LINKS_PER_DAY`), 15 je IP und Stunde, Honeypot.
|
||||||
|
|
||||||
|
Tests: `tests/Feature/Web/NewsroomClaimTest.php` (7).
|
||||||
|
|
||||||
|
**Noch nicht verlinkt.** Die Seite existiert, aber weder die Newsroom-Seite
|
||||||
|
noch die Veröffentlichungsmail zeigen darauf — das ist 1b.4 und hängt an E4
|
||||||
|
(ein Element pro Mail: Magic-Link-Hinweis oder Infoseite).
|
||||||
|
|
||||||
|
**Zu 1b.3 vorab:** Die Firmenseite im Kundenbereich kann heute Name,
|
||||||
|
Adresse, E-Mail, Telefon, Website, Land, Logo und Pressekontakte pflegen.
|
||||||
|
Was 1b.3 ergänzt: Beschreibungstext (durch die Rot-Ebene), Bilder (mit
|
||||||
|
KI-Deklaration und Audit-Log), und die Leitplanke, dass Links im Profiltext
|
||||||
|
systemseitig gesetzt werden.
|
||||||
|
|
|
||||||
|
|
@ -537,4 +537,20 @@ return [
|
||||||
'dispute_text' => 'Der Anbieter ist nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). Die frühere Plattform der EU-Kommission zur Online-Streitbeilegung wurde zum 20. Juli 2025 eingestellt.',
|
'dispute_text' => 'Der Anbieter ist nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). Die frühere Plattform der EU-Kommission zur Online-Streitbeilegung wurde zum 20. Juli 2025 eingestellt.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'newsroom_claim' => [
|
||||||
|
'title' => 'Diesen Newsroom verwalten',
|
||||||
|
'meta' => 'Verwaltungszugang zum Newsroom über die hinterlegte Firmen-E-Mail — kostenlos, ohne Passwort.',
|
||||||
|
'eyebrow' => 'Newsroom',
|
||||||
|
'breadcrumb' => 'Verwalten',
|
||||||
|
'intro' => 'Der Newsroom von :company gehört dem Unternehmen selbst. Wer die hinterlegte Firmen-E-Mail liest, kann Profil, Logo und Pressekontakte pflegen — kostenlos und ohne Passwort. Wir senden einen Anmeldelink an:',
|
||||||
|
'intro_no_email' => 'Für den Newsroom von :company ist keine E-Mail-Adresse hinterlegt. Bitte wenden Sie sich über das Kontaktformular an uns.',
|
||||||
|
'submit' => 'Anmeldelink senden',
|
||||||
|
'flash_sent' => 'Falls die hinterlegte Adresse erreichbar ist, haben wir einen Anmeldelink verschickt. Er ist 15 Minuten gültig.',
|
||||||
|
'throttle_company' => 'Für diesen Newsroom wurden heute bereits mehrere Anmeldelinks angefordert. Bitte prüfen Sie das Postfach oder versuchen Sie es morgen erneut.',
|
||||||
|
'throttle_ip' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.',
|
||||||
|
'wrong_address' => 'Die Adresse stimmt nicht mehr? Dann melden Sie sich mit der Adresse an, die Sie erreichen können — sofern sie an einem Pressekontakt hinterlegt ist — oder schreiben Sie uns über das Kontaktformular.',
|
||||||
|
'login_link' => 'Anmeldung per E-Mail-Link',
|
||||||
|
'back' => 'Zum Newsroom',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -536,4 +536,20 @@ return [
|
||||||
'dispute_text' => 'The provider is neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board (Section 36 VSBG). The former EU Commission platform for online dispute resolution was discontinued on 20 July 2025.',
|
'dispute_text' => 'The provider is neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board (Section 36 VSBG). The former EU Commission platform for online dispute resolution was discontinued on 20 July 2025.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'newsroom_claim' => [
|
||||||
|
'title' => 'Manage this newsroom',
|
||||||
|
'meta' => 'Administrative access to the newsroom via the registered company e-mail — free, no password.',
|
||||||
|
'eyebrow' => 'Newsroom',
|
||||||
|
'breadcrumb' => 'Manage',
|
||||||
|
'intro' => 'The newsroom of :company belongs to the company itself. Whoever reads the registered company e-mail can maintain profile, logo and press contacts — free of charge and without a password. We will send a login link to:',
|
||||||
|
'intro_no_email' => 'No e-mail address is registered for the newsroom of :company. Please contact us via the contact form.',
|
||||||
|
'submit' => 'Send login link',
|
||||||
|
'flash_sent' => 'If the registered address is reachable, we have sent a login link. It is valid for 15 minutes.',
|
||||||
|
'throttle_company' => 'Several login links have already been requested for this newsroom today. Please check the mailbox or try again tomorrow.',
|
||||||
|
'throttle_ip' => 'Too many requests. Please try again later.',
|
||||||
|
'wrong_address' => 'The address is no longer correct? Then sign in with the address you can reach — provided it is registered with a press contact — or write to us via the contact form.',
|
||||||
|
'login_link' => 'Sign in via e-mail link',
|
||||||
|
'back' => 'To the newsroom',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
75
resources/views/web/newsroom-claim.blade.php
Normal file
75
resources/views/web/newsroom-claim.blade.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
@extends('web.layouts.web-master')
|
||||||
|
|
||||||
|
@section('title', __('static.newsroom_claim.title'))
|
||||||
|
@section('meta_description', __('static.newsroom_claim.meta'))
|
||||||
|
|
||||||
|
{{--
|
||||||
|
„Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die
|
||||||
|
hinterlegte Firmen-E-Mail. Der Besucher gibt keine Adresse ein — der Link
|
||||||
|
geht an das, was an der Firma steht, und er sieht nur die maskierte
|
||||||
|
Adresse. Bewusst kein Angebot, kein Preis: Der Zugang ist kostenlos, und
|
||||||
|
die Seite bleibt es auch (Anti-Zombie-Zusage).
|
||||||
|
--}}
|
||||||
|
@section('content')
|
||||||
|
<x-web.static-page
|
||||||
|
:eyebrow="__('static.newsroom_claim.eyebrow')"
|
||||||
|
:title="__('static.newsroom_claim.title')"
|
||||||
|
:breadcrumb="__('static.newsroom_claim.breadcrumb')"
|
||||||
|
>
|
||||||
|
<div class="max-w-[640px]">
|
||||||
|
<p class="text-[15px] font-semibold text-ink mb-4">{{ $company->name }}</p>
|
||||||
|
|
||||||
|
@if (session('newsroom-claim-status'))
|
||||||
|
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
|
||||||
|
{{ session('newsroom-claim-status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@error('claim')
|
||||||
|
<div class="mb-6 px-4 py-3 border border-red-300 bg-red-50 text-[13px] text-red-700" role="alert">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
@if ($recipients->isEmpty() || session('newsroom-claim-none'))
|
||||||
|
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-6">
|
||||||
|
{{ __('static.newsroom_claim.intro_no_email', ['company' => $company->name]) }}
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-4">
|
||||||
|
{{ __('static.newsroom_claim.intro', ['company' => $company->name]) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mb-6 text-[14px] font-mono text-ink">
|
||||||
|
@foreach ($recipients as $recipient)
|
||||||
|
<li>{{ $recipient }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('newsroom.claim.store', ['slug' => $company->slug]) }}" class="space-y-5">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
{{-- Honeypot --}}
|
||||||
|
<div class="hidden" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" value="{{ old('website') }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 bg-brand text-white text-[13px] font-semibold hover:opacity-90">
|
||||||
|
{{ __('static.newsroom_claim.submit') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="mt-8 text-[12.5px] text-ink-2 leading-relaxed">
|
||||||
|
{{ __('static.newsroom_claim.wrong_address') }}
|
||||||
|
<a href="{{ rtrim((string) config('domains.domain_portal_url'), '/') }}/anmeldelink" class="underline underline-offset-2 hover:text-ink">{{ __('static.newsroom_claim.login_link') }}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mt-4 text-[12.5px]">
|
||||||
|
<a href="{{ route('newsroom', ['slug' => $company->slug]) }}" class="underline underline-offset-2 text-ink-2 hover:text-ink">← {{ __('static.newsroom_claim.back') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-web.static-page>
|
||||||
|
@endsection
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Web\CategoryController;
|
||||||
use App\Http\Controllers\Web\ContactController;
|
use App\Http\Controllers\Web\ContactController;
|
||||||
use App\Http\Controllers\Web\HomeController;
|
use App\Http\Controllers\Web\HomeController;
|
||||||
use App\Http\Controllers\Web\LegacyMediaRedirectController;
|
use App\Http\Controllers\Web\LegacyMediaRedirectController;
|
||||||
|
use App\Http\Controllers\Web\NewsroomClaimController;
|
||||||
use App\Http\Controllers\Web\NewsroomDetailController;
|
use App\Http\Controllers\Web\NewsroomDetailController;
|
||||||
use App\Http\Controllers\Web\NewsroomsController;
|
use App\Http\Controllers\Web\NewsroomsController;
|
||||||
use App\Http\Controllers\Web\PreiseController;
|
use App\Http\Controllers\Web\PreiseController;
|
||||||
|
|
@ -242,6 +243,12 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
|
||||||
Route::get('suche', SucheController::class)->name('suche');
|
Route::get('suche', SucheController::class)->name('suche');
|
||||||
Route::get('newsrooms', NewsroomsController::class)->name('newsrooms');
|
Route::get('newsrooms', NewsroomsController::class)->name('newsrooms');
|
||||||
Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom');
|
Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom');
|
||||||
|
// „Diesen Newsroom verwalten" (Roadmap 1b.2): Anmeldelink an die
|
||||||
|
// hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit.
|
||||||
|
Route::get('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'show'])->name('newsroom.claim');
|
||||||
|
Route::post('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'store'])
|
||||||
|
->middleware('throttle:10,1')
|
||||||
|
->name('newsroom.claim.store');
|
||||||
Route::view('api', 'web.api')->name('api');
|
Route::view('api', 'web.api')->name('api');
|
||||||
Route::view('hilfe', 'web.hilfe')->name('hilfe');
|
Route::view('hilfe', 'web.hilfe')->name('hilfe');
|
||||||
Route::view('impressum', 'web.impressum')->name('impressum');
|
Route::view('impressum', 'web.impressum')->name('impressum');
|
||||||
|
|
|
||||||
154
tests/Feature/Web/NewsroomClaimTest.php
Normal file
154
tests/Feature/Web/NewsroomClaimTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ConversionEventType;
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Mail\MagicLoginLink;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\CompanyEmailGroup;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\ConversionEvent;
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Company\CompanyMergeService;
|
||||||
|
use App\Services\Company\CompanyRemovalService;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die
|
||||||
|
* hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit.
|
||||||
|
*/
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
Mail::fake();
|
||||||
|
RateLimiter::clear('newsroom-claim|ip|127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
function claimCompany(array $attributes = []): Company
|
||||||
|
{
|
||||||
|
return Company::factory()->create(array_merge([
|
||||||
|
'portal' => Portal::Businessportal24->value,
|
||||||
|
'name' => 'Muster GmbH',
|
||||||
|
'slug' => 'muster-gmbh',
|
||||||
|
'email' => 'info@muster.de',
|
||||||
|
'is_active' => true,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimUrl(string $slug = 'muster-gmbh'): string
|
||||||
|
{
|
||||||
|
return 'https://businessportal24.test/de/newsroom/'.$slug.'/verwalten';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the claim page is reachable without a single published release and shows only a masked address', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
claimCompany();
|
||||||
|
|
||||||
|
// Die öffentliche Newsroom-Seite gibt es noch nicht — den Zugang schon.
|
||||||
|
$this->get('https://businessportal24.test/de/newsroom/muster-gmbh')->assertNotFound();
|
||||||
|
|
||||||
|
$this->get(claimUrl())
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeText('Muster GmbH')
|
||||||
|
->assertSeeText('i***@muster.de')
|
||||||
|
->assertDontSeeText('info@muster.de');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting the link creates the account, sends the login link with profile_claim on the token and lands on the company page', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$company = claimCompany();
|
||||||
|
|
||||||
|
$this->post(claimUrl())->assertRedirect()->assertSessionHas('newsroom-claim-status');
|
||||||
|
|
||||||
|
$user = User::query()->where('email', 'info@muster.de')->firstOrFail();
|
||||||
|
expect($user->accessibleCompanyIds())->toContain($company->id);
|
||||||
|
|
||||||
|
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
|
||||||
|
|
||||||
|
$link = MagicLink::query()->where('user_id', $user->id)->where('purpose', 'login')->latest('id')->firstOrFail();
|
||||||
|
expect($link->source)->toBe(ConversionSource::ProfileClaim->value)
|
||||||
|
->and($link->payload['company_id'])->toBe($company->id);
|
||||||
|
|
||||||
|
// Token aus der Mail nachstellen: Der Login landet auf der Firma, nicht
|
||||||
|
// auf dem Dashboard — und Stufe 2 der Leiter trägt die Herkunft.
|
||||||
|
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
|
||||||
|
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
|
||||||
|
->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false));
|
||||||
|
|
||||||
|
expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source)
|
||||||
|
->toBe(ConversionSource::ProfileClaim);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a company email the link goes to the press contacts', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$company = claimCompany(['email' => null]);
|
||||||
|
Contact::factory()->for($company)->create(['email' => 'Paula@muster.de', 'portal' => 'businessportal24']);
|
||||||
|
Contact::factory()->for($company)->create(['email' => 'peter@muster.de', 'portal' => 'businessportal24']);
|
||||||
|
|
||||||
|
$this->get(claimUrl())->assertSeeText('p***@muster.de');
|
||||||
|
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without any address the page explains it and sends nothing', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
claimCompany(['email' => null]);
|
||||||
|
|
||||||
|
$this->get(claimUrl())->assertOk()->assertSeeText('keine E-Mail-Adresse hinterlegt');
|
||||||
|
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none');
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links per newsroom are limited per day', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$company = claimCompany();
|
||||||
|
RateLimiter::clear('newsroom-claim|company|'.$company->id);
|
||||||
|
config()->set('newsroom.claim.links_per_day', 2);
|
||||||
|
|
||||||
|
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
|
||||||
|
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
|
||||||
|
$this->post(claimUrl())->assertSessionHasErrors('claim');
|
||||||
|
|
||||||
|
Mail::assertQueuedCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a removed newsroom answers 410 and a merged-away slug redirects to the survivor', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$gone = claimCompany(['slug' => 'weg-gmbh', 'email' => 'weg@muster.de']);
|
||||||
|
app(CompanyRemovalService::class)->markGone($gone);
|
||||||
|
|
||||||
|
$this->get(claimUrl('weg-gmbh'))->assertStatus(410);
|
||||||
|
$this->post(claimUrl('weg-gmbh'))->assertStatus(410);
|
||||||
|
|
||||||
|
// Zusammenführung: alte Hälfte auf PE mit anderem Slug geht im
|
||||||
|
// B24-Überlebenden auf → der Zugang folgt dem 301 der Newsroom-Seite.
|
||||||
|
claimCompany();
|
||||||
|
claimCompany(['portal' => Portal::Presseecho->value, 'slug' => 'muster']);
|
||||||
|
CompanyEmailGroup::query()->create([
|
||||||
|
'email' => 'info@muster.de', 'classification' => 'dublette', 'companies_count' => 2, 'distinct_names' => 1,
|
||||||
|
'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(),
|
||||||
|
]);
|
||||||
|
$service = app(CompanyMergeService::class);
|
||||||
|
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
||||||
|
|
||||||
|
$this->get('https://presseecho.test/de/newsroom/muster/verwalten')
|
||||||
|
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh/verwalten')
|
||||||
|
->assertStatus(301);
|
||||||
|
|
||||||
|
$this->get('https://businessportal24.test/de/newsroom/unbekannt/verwalten')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the honeypot answers neutrally without sending', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
claimCompany();
|
||||||
|
|
||||||
|
$this->post(claimUrl(), ['website' => 'http://spam'])->assertSessionHas('newsroom-claim-status');
|
||||||
|
|
||||||
|
Mail::assertNothingQueued();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue