customer: Sichtbarkeit belegen - Aufrufe im Verlauf, Newsroom-Uebersicht (Roadmap 1c)

- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je
  Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind
  null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B).
- Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher
  sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel.
- Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage.
- Claim aus der Veroeffentlichungsmail landet auf der Meldung
  (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 11:53:09 +00:00
parent 36691a5b13
commit 09ec866aac
14 changed files with 676 additions and 59 deletions

View file

@ -6,6 +6,7 @@ use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Http\RedirectResponse;
@ -106,6 +107,18 @@ class MagicLinkConsumeController extends Controller
if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin()
&& $this->companies->findFor($user, $landingCompanyId) !== null) {
$home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false);
// Aus der Veröffentlichungsmail (Roadmap 1c.2): direkt auf die
// Meldung mit ihren Aufrufen, wenn der Account sie sehen darf.
$landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
if ($landingPressReleaseId > 0) {
$pressRelease = PressRelease::query()->withoutGlobalScopes()->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
if ($pressRelease !== null && $user->can('view', $pressRelease)) {
$home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false);
}
}
}
return redirect($home);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Enums\ConversionSource;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService;
@ -70,11 +71,27 @@ 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->sourceOf($request));
$this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company));
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
}
/**
* Kam der Besucher aus der Veröffentlichungsmail einer Meldung (`pr` im
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
*/
private function landingPressReleaseId(Request $request, Company $company): ?int
{
$id = (int) $request->input('pr', 0);
if ($id <= 0) {
return null;
}
return PressRelease::query()->withoutGlobalScopes()->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
}
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
{
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);

View file

@ -42,7 +42,7 @@ class ContactAccessService
* - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet.
* - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion.
*/
public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null): bool
public function requestMagicAccess(string $email, ?string $ip = null, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): bool
{
$email = mb_strtolower(trim($email));
$contacts = $this->contactsFor($email);
@ -61,7 +61,7 @@ class ContactAccessService
}
$this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts);
$this->sendLoginLink($user, $ip, $source, $landingCompanyId);
$this->sendLoginLink($user, $ip, $source, $landingCompanyId, $landingPressReleaseId);
return true;
}
@ -200,13 +200,19 @@ class ContactAccessService
/**
* Versendet einen regulären Login-Magic-Link an den Account.
*/
private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null): void
private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): void
{
// Landung nach dem Login (Roadmap 1b.2/1c.2): die Firma, um die es
// ging — und, wenn der Weg über eine Veröffentlichungsmail kam, die
// konkrete Meldung mit ihren Aufrufen.
$generated = $this->magicLinks->createLoginLink(
$user,
$ip,
source: $source,
payload: $landingCompanyId !== null ? ['company_id' => $landingCompanyId] : [],
payload: array_filter([
'company_id' => $landingCompanyId,
'press_release_id' => $landingPressReleaseId,
]),
);
Mail::to($user->email)->queue((new MagicLoginLink(

View file

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

View file

@ -0,0 +1,196 @@
<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* Sichtbarkeit belegen (Roadmap 1c): Aufrufe je Meldung und je Firma, als
* Rohzahlen — Entscheidung E2, keine Einordnung gegen den Portalschnitt.
*
* Zwei Quellen, bewusst getrennt gelesen:
*
* - `press_releases.hits` ist die Summe seit jeher, inklusive Altsystem.
* Sie wird vom Scheduler aus den Ereignissen nachgeführt und ist die
* einzige Zahl, die auch für Meldungen vor Juli 2026 stimmt.
* - `press_release_events` trägt je Aufruf einen Zeitstempel (seit
* 17.07.2026, 13 Monate Aufbewahrung) und liefert den Verlauf. Ein
* Tageswert älter als die Aufbewahrung existiert nicht mehr — die
* Verlaufsgrafik darf ihn nicht als „0 Aufrufe" ausgeben.
*
* Der Indexierungsstatus ist in dieser Stufe bewusst ohne Fremddienst
* (Entscheidung 16.09.2026, Variante B): „in der Sitemap seit" plus ein
* Link auf die Suche. Die Search-Console-Anbindung kommt als eigenes Paket.
*/
class VisibilityStats
{
/**
* Kennzahlen einer Firma über alle ihre Meldungen — unabhängig davon, wer
* sie eingereicht hat. Ein Beansprucher sieht damit auch, was connektar
* für ihn eingeliefert hat.
*
* @return array{releases: int, published: int, views_total: int, views_recent: int, last_published_at: Carbon|null}
*/
public function companySummary(Company $company, int $days = 30): array
{
$base = PressRelease::query()->withoutGlobalScopes()->where('company_id', $company->id);
$row = $base->clone()
->selectRaw('count(*) as releases')
->selectRaw('sum(case when status = ? then 1 else 0 end) as published', [PressReleaseStatus::Published->value])
->selectRaw('coalesce(sum(hits), 0) as views_total')
->selectRaw('max(published_at) as last_published_at')
->toBase()
->first();
$recent = PressReleaseEvent::query()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', now()->subDays($days))
->whereIn('press_release_id', $base->clone()->select('id'))
->count();
return [
'releases' => (int) ($row->releases ?? 0),
'published' => (int) ($row->published ?? 0),
'views_total' => (int) ($row->views_total ?? 0),
'views_recent' => $recent,
'last_published_at' => ($row->last_published_at ?? null) ? Carbon::parse($row->last_published_at) : null,
];
}
/**
* Aufrufe der letzten N Tage je Meldung, für eine Liste.
*
* @param list<int> $pressReleaseIds
* @return array<int, int>
*/
public function recentViewsFor(array $pressReleaseIds, int $days = 30): array
{
if ($pressReleaseIds === []) {
return [];
}
return PressReleaseEvent::query()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', now()->subDays($days))
->whereIn('press_release_id', $pressReleaseIds)
->selectRaw('press_release_id, count(*) as views')
->groupBy('press_release_id')
->pluck('views', 'press_release_id')
->map(fn ($views): int => (int) $views)
->all();
}
/**
* Tagesreihe der Aufrufe, lückenlos über die letzten N Tage (heute
* eingeschlossen). Tage vor Beginn der Ereignisaufzeichnung oder vor der
* Veröffentlichung sind `null`, nicht 0 — es gab dort keine Messung.
*
* @return list<array{date: Carbon, views: int|null}>
*/
public function dailyViews(PressRelease $pressRelease, int $days = 30): array
{
$days = max(1, min($days, (int) config('press_releases.events.retention_days', 395)));
$start = now()->startOfDay()->subDays($days - 1);
$counts = $pressRelease->events()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', $start)
->selectRaw('date(created_at) as day, count(*) as views')
->groupBy('day')
->pluck('views', 'day');
$measuredFrom = $this->measurementStart($pressRelease);
$series = [];
for ($i = 0; $i < $days; $i++) {
$day = $start->copy()->addDays($i);
$series[] = [
'date' => $day,
'views' => $measuredFrom !== null && $day->lt($measuredFrom->copy()->startOfDay())
? null
: (int) ($counts[$day->toDateString()] ?? 0),
];
}
return $series;
}
/**
* Einzelwerte einer Meldung für das Panel „Sichtbarkeit".
*
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, search_url: string|null}
*/
public function releaseSummary(PressRelease $pressRelease): array
{
$views = $pressRelease->events()
->where('type', PressReleaseEventType::View->value)
->selectRaw('min(created_at) as first_view, max(created_at) as last_view')
->toBase()
->first();
$shares = $pressRelease->events()
->where('type', PressReleaseEventType::Share->value)
->selectRaw('channel, count(*) as clicks')
->groupBy('channel')
->pluck('clicks', 'channel')
->mapWithKeys(fn ($clicks, $channel): array => [
PressReleaseShareChannel::tryFrom((string) $channel)?->label() ?? (string) $channel => (int) $clicks,
])
->sortDesc();
$links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : [];
$firstUrl = $links[0]['url'] ?? null;
return [
'hits' => (int) $pressRelease->hits,
'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null,
'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null,
'shares' => $shares->all(),
'shares_total' => (int) $shares->sum(),
// Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
// über Google.
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
'search_url' => $firstUrl !== null ? 'https://www.google.com/search?q='.rawurlencode('site:'.preg_replace('#^https?://#', '', $firstUrl)) : null,
];
}
/**
* Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der
* spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung.
*/
private function measurementStart(PressRelease $pressRelease): ?Carbon
{
$candidates = collect([
$pressRelease->published_at,
$this->eventsSince(),
])->filter();
return $candidates->isEmpty() ? null : $candidates->max();
}
private function eventsSince(): ?Carbon
{
$since = config('press_releases.events.recorded_since');
return $since ? Carbon::parse((string) $since) : null;
}
/**
* Umschaltbare Zeiträume der Verlaufsgrafik.
*
* @return Collection<int, int>
*/
public static function ranges(): Collection
{
return collect([30, 90]);
}
}