newsroom: Hinweis „Diesen Newsroom verwalten“ auf der Seite und in der Mail (Roadmap 1b.4, E4)

E4 (15.09.2026): Die Veröffentlichungsmail trägt den Hinweis am Ende, als
Hinweis, nicht als Angebot. Auf der Newsroom-Seite eine Zeile unter dem
Profil. Der Mail-Link ist mit src=mail_publish getaggt; die Claim-Seite
reicht die Herkunft bis auf das Token durch, sodass die Leiter „aus der
Mail beansprucht“ von „auf der Seite entdeckt“ unterscheidet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 11:05:53 +00:00
parent 0c1c072487
commit 7f714fb664
11 changed files with 141 additions and 5 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Web;
use App\Enums\ConversionSource;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Scopes\PortalScope;
@ -68,7 +69,7 @@ class NewsroomClaimController extends Controller
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
$this->claims->request($company, $request->ip());
$this->claims->request($company, $request->ip(), $this->sourceOf($request));
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
}
@ -101,6 +102,20 @@ class NewsroomClaimController extends Controller
abort(404);
}
/**
* Kam der Besucher aus einer Mail (`src` aus dem Link, per Formular
* weitergereicht), trägt das Token diese Herkunft — sonst ist es der
* Hinweis auf der Newsroom-Seite.
*/
private function sourceOf(Request $request): ConversionSource
{
$source = ConversionSource::parse($request->filled('src') ? (string) $request->input('src') : null);
return in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true)
? $source
: ConversionSource::ProfileClaim;
}
private function ensureNotRateLimited(Request $request, int $companyId): void
{
$perDay = (int) config('newsroom.claim.links_per_day', 3);

View file

@ -33,6 +33,12 @@ class PressReleasePublished extends Mailable
*/
public string $recipientName;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.4, Entscheidung E4 vom
* 15.09.2026): Hinweis am Ende der Mail, kein Angebot. Null ohne Firma.
*/
public ?string $manageNewsroomUrl = null;
public function __construct(
public readonly User $user,
public readonly PressRelease $pressRelease,
@ -54,9 +60,30 @@ class PressReleasePublished extends Mailable
$pressRelease->publicPortalLinks(),
);
$this->manageNewsroomUrl = $this->manageNewsroomUrl($pressRelease);
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
}
/**
* Verwaltungsseite des Newsrooms auf dem Portal, auf dem die Meldung
* erschienen ist (bei beiden: presseecho). Nur für Firmen, die noch als
* Newsroom existieren — nicht 410-entfernt.
*/
private function manageNewsroomUrl(PressRelease $pressRelease): ?string
{
$company = $pressRelease->company;
if ($company === null || ! $company->slug || $company->isGone() || $this->portalLinks === []) {
return null;
}
$host = rtrim((string) parse_url($this->portalLinks[0]['url'], PHP_URL_SCHEME).'://'.parse_url($this->portalLinks[0]['url'], PHP_URL_HOST), '/');
$edition = in_array($pressRelease->language, ['de', 'en'], true) ? $pressRelease->language : 'de';
return self::tagged(sprintf('%s/%s/newsroom/%s/verwalten', $host, $edition, $company->slug), $pressRelease->id);
}
public function envelope(): Envelope
{
return new Envelope(

View file

@ -77,12 +77,12 @@ class NewsroomClaimService
* 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
public function request(Company $company, ?string $ip, ConversionSource $source = ConversionSource::ProfileClaim): int
{
$sent = 0;
foreach ($this->recipients($company) as $email) {
if ($this->contactAccess->requestMagicAccess($email, $ip, ConversionSource::ProfileClaim, $company->getKey())) {
if ($this->contactAccess->requestMagicAccess($email, $ip, $source, $company->getKey())) {
$sent++;
}
}

View file

@ -121,7 +121,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
| ~~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 — **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.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 — **gebaut 15.09.2026** (E4: Hinweis unten in der Mail; Herkunft `mail_publish` läuft bis auf das Token durch) | S | 1b.2 |
| ~~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 |
@ -234,7 +234,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
| ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 |
| ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 |
| E3 | Anzahl kostenloser Prüfläufe und Preis der weiteren in Credits | 1d.4 |
| E4 | Trägt die Veröffentlichungsmail den Magic-Link-Hinweis oder den Link auf die Infoseite; ein Element pro Mail | 1b.4, 1d.2 |
| ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 |
| E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 |
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |

View file

@ -955,3 +955,25 @@ 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.
## 1b.4 — Hinweise (E4 entschieden 15.09.2026)
**E4:** Die Veröffentlichungsmail trägt den Magic-Link-Hinweis — am Ende, als
Hinweis, nicht als Angebot. Damit bekommt der Link auf die Infoseite (1d.2)
keinen Platz in dieser Mail (ein Element pro Mail).
- **Newsroom-Seite:** eine Zeile unter dem Firmenprofil („Sie vertreten
…? Profil, Logo und Pressekontakte lassen sich kostenlos pflegen. Diesen
Newsroom verwalten"), `rel="nofollow"`, 12 px, ink-3.
- **Veröffentlichungsmail:** ein Absatz vor dem Hilfe-Hinweis, in derselben
grauen Kleinschrift wie der bestehende Login-Hinweis. Link auf
`/newsroom/{slug}/verwalten` des Portals, auf dem die Meldung erschien
(bei beiden: presseecho), getaggt mit `src=mail_publish&pr=`. Kein Hinweis
ohne Firma oder bei 410-entfernter Firma.
- **Herkunft läuft durch:** Die Claim-Seite reicht `src` aus dem Mail-Link
per verstecktem Feld weiter; das Token trägt dann `mail_publish` statt
`profile_claim`. Stufe 1 (Klick) zeichnet die globale Middleware beim
Aufruf der Claim-Seite ohnehin auf. So unterscheidet die Leiter „aus der
Mail beansprucht" von „auf der Seite entdeckt".
Tests: drei weitere in `NewsroomClaimTest` (Seite, Mail, Herkunft).

View file

@ -284,6 +284,8 @@ return [
'total_hits_sub' => 'über alle Meldungen',
'latest' => 'Aktuellste Meldung',
'empty' => 'Aktuell keine weiteren Meldungen in diesem Newsroom.',
'manage_hint' => 'Sie vertreten :company? Profil, Logo und Pressekontakte lassen sich kostenlos pflegen.',
'manage_link' => 'Diesen Newsroom verwalten',
'own_title' => 'Eigenen Newsroom starten',
'own_text' => 'Bündeln Sie alle Ihre Pressemitteilungen unter einer eigenen Marken-URL – mit Logo, Branchen-Zuordnung und SEO-Vorteil.',
'own_cta' => 'Newsroom anlegen',

View file

@ -279,6 +279,8 @@ return [
'total_hits_sub' => 'across all releases',
'latest' => 'Latest release',
'empty' => 'No further releases in this newsroom at the moment.',
'manage_hint' => 'You represent :company? Profile, logo and press contacts can be maintained free of charge.',
'manage_link' => 'Manage this newsroom',
'own_title' => 'Start your own newsroom',
'own_text' => 'Bundle all your press releases under your own branded URL – with logo, industry mapping and SEO benefit.',
'own_cta' => 'Create newsroom',

View file

@ -35,6 +35,16 @@
dort mit Ihrem Passwort oder per Magic-Link (Anmelde-Link per E-Mail) möglich.
</p>
{{-- 3) Verwaltungszugang zum Newsroom (Roadmap 1b.4, E4): ein Hinweis
am Ende, kein Angebot — der Zugang ist kostenlos. --}}
@if ($manageNewsroomUrl)
<p style="font-size: 12.5px; color: #6b7280;">
Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
Profil, Logo und Pressekontakte. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten</a>
</p>
@endif
<div class="note">
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>

View file

@ -48,6 +48,8 @@
<form method="POST" action="{{ route('newsroom.claim.store', ['slug' => $company->slug]) }}" class="space-y-5">
@csrf
{{-- Herkunft aus dem Mail-Link (?src=) bis auf das Token durchreichen. --}}
<input type="hidden" name="src" value="{{ request()->query('src') }}" />
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">

View file

@ -121,6 +121,13 @@
<p class="font-serif text-[15.5px] leading-[1.6] m-0 mt-5 pt-5 border-t border-bg-rule text-ink-2">{{ Str::limit($boilerplate, 360) }}</p>
@endif
{{-- Verwaltungszugang (Roadmap 1b.4): ein Hinweis, kein Angebot.
Der Zugang ist kostenlos und führt zur Firmen-E-Mail. --}}
<p class="text-[12px] text-ink-3 m-0 mt-5 pt-4 border-t border-bg-rule">
{{ __('web.newsroom_page.manage_hint', ['company' => $company->name]) }}
<a href="{{ route('newsroom.claim', ['slug' => $company->slug]) }}" rel="nofollow" class="font-medium underline underline-offset-2 hover:text-ink transition-colors">{{ __('web.newsroom_page.manage_link') }}</a>
</p>
@if ($categories->isNotEmpty())
<div class="flex flex-wrap items-center gap-2 mt-5 pt-5 border-t border-bg-rule">
<span class="eyebrow muted text-[10px] mr-1.5">{{ __('web.newsroom_page.industries') }}</span>

View file

@ -4,11 +4,13 @@ use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Mail\MagicLoginLink;
use App\Mail\PressReleasePublished;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\Contact;
use App\Models\ConversionEvent;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyRemovalService;
@ -152,3 +154,50 @@ test('the honeypot answers neutrally without sending', function () {
Mail::assertNothingQueued();
});
// ------------------------------------------------- 1b.4 Hinweise
test('the newsroom page links to the claim page as a hint', function () {
/** @var TestCase $this */
$company = claimCompany();
PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
'company_id' => $company->id, 'published_at' => now()->subDay(), 'language' => 'de',
]);
$this->get('https://businessportal24.test/de/newsroom/muster-gmbh')
->assertOk()
->assertSeeText('Diesen Newsroom verwalten')
->assertSee('/de/newsroom/muster-gmbh/verwalten', false);
});
test('the publish mail carries the manage hint at the bottom, tagged with its origin', function () {
/** @var TestCase $this */
$company = claimCompany();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
'company_id' => $company->id, 'language' => 'de',
]);
$mail = new PressReleasePublished($release->user, $release);
// E4 (15.09.2026): Hinweis in der Mail, unten, kein Angebot.
expect($mail->manageNewsroomUrl)->toStartWith('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten?')
->and($mail->manageNewsroomUrl)->toContain('src='.ConversionSource::MailPublish->value)
->and($mail->render())->toContain('Diesen Newsroom verwalten');
// Ohne Firma kein Hinweis — und nichts bricht.
$orphan = PressRelease::factory()->published()->create(['company_id' => null]);
expect((new PressReleasePublished($orphan->user, $orphan))->manageNewsroomUrl)->toBeNull();
});
test('a claim coming from the publish mail keeps the mail origin on the token', function () {
/** @var TestCase $this */
$company = claimCompany();
$this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value])->assertSessionHas('newsroom-claim-status');
$user = User::query()->where('email', 'info@muster.de')->firstOrFail();
$link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
expect($link->source)->toBe(ConversionSource::MailPublish->value)
->and($link->payload['company_id'])->toBe($company->id);
});