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 a00f9dc79a
commit 3ef1b84600
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\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity; use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink; use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Services\Conversion\ConversionRecorder; use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext; use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -106,6 +107,18 @@ class MagicLinkConsumeController extends Controller
if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin() if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin()
&& $this->companies->findFor($user, $landingCompanyId) !== null) { && $this->companies->findFor($user, $landingCompanyId) !== null) {
$home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false); $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); return redirect($home);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Enums\ConversionSource; use App\Enums\ConversionSource;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company; use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup; use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService; use App\Services\Company\NewsroomClaimService;
@ -70,11 +71,27 @@ class NewsroomClaimController extends Controller
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist // Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar. // 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')); 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 private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
{ {
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug); $survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);

View file

@ -42,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, ?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)); $email = mb_strtolower(trim($email));
$contacts = $this->contactsFor($email); $contacts = $this->contactsFor($email);
@ -61,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, $source, $landingCompanyId); $this->sendLoginLink($user, $ip, $source, $landingCompanyId, $landingPressReleaseId);
return true; return true;
} }
@ -200,13 +200,19 @@ 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, ?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( $generated = $this->magicLinks->createLoginLink(
$user, $user,
$ip, $ip,
source: $source, 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( 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 * Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen
* tatsächlich etwas ging — deaktivierte Konten bekommen nichts. * 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; $sent = 0;
foreach ($this->recipients($company) as $email) { 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++; $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]);
}
}

View file

@ -23,6 +23,12 @@ return [
'events' => [ 'events' => [
'retention_days' => (int) env('PRESS_RELEASE_EVENTS_RETENTION_DAYS', 395), 'retention_days' => (int) env('PRESS_RELEASE_EVENTS_RETENTION_DAYS', 395),
// Seit wann Aufrufe als Ereignis mit Zeitstempel geschrieben werden
// (Umbau vom Zähler auf press_release_events). Davor gibt es nur die
// Summe `hits` — die Verlaufsgrafik der Absenderansicht (Roadmap
// 1c.1) zeigt frühere Tage als „keine Messung", nicht als 0.
'recorded_since' => env('PRESS_RELEASE_EVENTS_RECORDED_SINCE', '2026-07-17'),
], ],
]; ];

View file

@ -136,9 +136,9 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit | | Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---| |---|---|---|---|
| 1c.1 | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt | M | 0.1 | | ~~1c.1~~ | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt — **gebaut 16.09.2026**: Panel „Sichtbarkeit" auf der Meldungsseite, Tagesbalken 30/90 Tage (Ereignisse seit 17.07.2026, davor „keine Messung"), Rohzahlen nach E2; Indexierung in **Variante B** („in der Sitemap seit", Link auf die Google-Suche) — Search-Console-Abgleich (Variante A) bewusst ans Ende geschoben | M | 0.1 |
| 1c.2 | Zugang zur Ansicht über Magic Link, auch ohne Konto | S | 1b.2 | | ~~1c.2~~ | Zugang zur Ansicht über Magic Link, auch ohne Konto — **16.09.2026**: der Claim-Weg aus 1b.2 trägt jetzt die Meldung (`pr`) bis auf das Token, der Login landet auf ihr; Mail-Hinweis um „Aufrufe einsehen" erweitert, bleibt ein Element (E4) | S | 1b.2 |
| 1c.3 | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten | M | 1a.3, 1c.1 | | ~~1c.3~~ | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten — **16.09.2026**: Firmenseite zeigt alle Meldungen der Firma (vorher nur eigene — Beansprucher sahen eine leere Liste), Aufrufe je Meldung und 30 Tage, Statistik-Panel echt | M | 1a.3, 1c.1 |
### 1d Infoseite und Prüfwerkzeug ### 1d Infoseite und Prüfwerkzeug

View file

@ -1280,3 +1280,63 @@ einen Job anstößt.
Deploy: `migrate --force`, dann einmal `php artisan mail:check-domains Deploy: `migrate --force`, dann einmal `php artisan mail:check-domains
--months=6 --limit=1500` (≈ 2 Minuten, nur DNS-Lesen). Danach ist der --months=6 --limit=1500` (≈ 2 Minuten, nur DNS-Lesen). Danach ist der
Bericht gefüllt. Bericht gefüllt.
---
# Teil 5 — Block 1c: Sichtbarkeit belegen (16.09.2026)
## Befunde vor dem Bau
- `press_release_events` trägt seit 17.07.2026 je Aufruf einen Zeitstempel,
Index `(press_release_id, type, created_at)`, Tagesreihe je Meldung 4 ms,
Aufbewahrung 13 Monate (Teil 2). Davor nur die Summe `hits`. Beginn steht
in `config/press_releases.php` (`events.recorded_since`).
- E2 bleibt: Rohzahlen, keine Einordnung.
- **Leerlauf nach dem Claim:** Die Firmenseite im Kundenbereich filterte
Meldungen nach `user_id`. Ein Beansprucher sah „Keine Pressemitteilungen
für diese Firma", obwohl connektar 68 eingereicht hatte — die Policy
erlaubte das Ansehen längst. Das Statistik-Panel war ein Platzhalter.
- Keine Suchmaschinen-Anbindung im Code. Entscheidung mit dem Nutzer:
**Indexierung Variante B** (ohne Fremddienst), Search Console (A) später
als Abgleich, „ganz nach hinten".
## 1c.3 — Newsroom-Übersicht
`VisibilityStats::companySummary()` (Meldungen, veröffentlicht, Aufrufe
gesamt aus `hits`, Aufrufe 30 Tage aus Ereignissen, zuletzt veröffentlicht)
und `recentViewsFor()` für die Liste. Firmenseite: alle Meldungen der
Firma nach `coalesce(published_at, created_at)`, Spalten Aufrufe und 30
Tage, Fremdeinreichungen als „eingereicht über Partner" markiert;
Statistik-Panel „Sichtbarkeit" echt.
## 1c.1 — Absenderansicht je Meldung
Panel „Sichtbarkeit" auf `me.press-releases.show`, nur für Meldungen, die
je online waren: Aufrufe gesamt (seit Veröffentlichung), Aufrufe im
Zeitraum (30/90 Tage umschaltbar, `?verlauf=`), Teilungen je Kanal,
Tagesbalken als reines SVG ohne Bibliothek. Tage vor Beginn der Zählung
oder vor der Veröffentlichung sind **`null`, nicht 0** — blass gezeichnet
mit Fußnote, damit die Summe nicht der Grafik widerspricht.
Auffindbarkeit: „In der Sitemap für Suchmaschinen seit <published_at>"
(Aussage über uns) plus Schaltfläche „Bei Google prüfen" (`site:`-Suche).
Die alten Kacheln Aufrufe/Geteilt im Status-Panel sind darin aufgegangen.
## 1c.2 — Zugang über Magic Link
War durch 1b.2 fast fertig (Konto, Firmenzuordnung, Policy). Ergänzt: Der
Mail-Link trug schon `pr=<id>`; die Claim-Seite reicht ihn als verstecktes
Feld durch, der Controller prüft die Zugehörigkeit zur Firma, das Token
bekommt `press_release_id`, der Login landet auf der Meldung
(`MagicLinkConsumeController`), sonst wie bisher auf der Firma. Wortlaut
in der Veröffentlichungsmail: „Diesen Newsroom verwalten und Aufrufe
einsehen" — weiterhin ein Element (E4).
Tests: `VisibilityStatsTest` (6), `NewsroomClaimTest` (+1, 11). Gesamt
1680 grün, phpstan für die neuen Dateien sauber.
**Nebenbefund:** phpstan meldet acht Punkte in `SettleUnpaidLegacyInvoices`
und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt
`dev/lagacy-rechnungen/` (Commit 36691a5) — nullsafe-Zugriffe auf nicht
nullbare Werte, eine immer-falsche Negation. Nicht angefasst.
Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig.

View file

@ -40,8 +40,8 @@
@if ($manageNewsroomUrl) @if ($manageNewsroomUrl)
<p style="font-size: 12.5px; color: #6b7280;"> <p style="font-size: 12.5px; color: #6b7280;">
Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen — 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> Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:<br>
<a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten</a> <a href="{{ $manageNewsroomUrl }}">Diesen Newsroom verwalten und Aufrufe einsehen</a>
</p> </p>
@endif @endif

View file

@ -7,6 +7,7 @@ use App\Services\Company\CompanyProfileCheck;
use App\Services\Conversion\ConversionRecorder; use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext; use App\Services\Customer\CustomerCompanyContext;
use App\Services\Image\ImageService; use App\Services\Image\ImageService;
use App\Services\PressRelease\VisibilityStats;
use App\Support\WebCaches; use App\Support\WebCaches;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -246,6 +247,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{ {
$user = auth()->user(); $user = auth()->user();
$context = app(CustomerCompanyContext::class); $context = app(CustomerCompanyContext::class);
$stats = app(VisibilityStats::class);
$company = $context $company = $context
->accessibleCompanyQuery($user) ->accessibleCompanyQuery($user)
->withCount(['contacts', 'pressReleases']) ->withCount(['contacts', 'pressReleases'])
@ -264,12 +266,18 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
->orderBy('first_name') ->orderBy('first_name')
->limit(10) ->limit(10)
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']), ->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'email', 'phone']),
'pressReleases' => PressRelease::withoutGlobalScopes() // Alle Meldungen der Firma, nicht nur die eigenen (Roadmap 1c.3):
->where('user_id', $user->id) // Wer den Newsroom beansprucht hat, soll sehen, was connektar
// für ihn eingeliefert hat — die Policy erlaubt das Firmen-
// mitgliedern ohnehin. Sortiert nach Veröffentlichung, Entwürfe
// nach Anlage.
'pressReleases' => $pressReleases = PressRelease::withoutGlobalScopes()
->where('company_id', $company->id) ->where('company_id', $company->id)
->latest() ->orderByRaw('coalesce(published_at, created_at) desc')
->limit(10) ->limit(15)
->get(['id', 'title', 'status', 'created_at', 'published_at']), ->get(['id', 'title', 'status', 'created_at', 'published_at', 'hits', 'user_id']),
'recentViews' => $stats->recentViewsFor($pressReleases->modelKeys(), 30),
'summary' => $stats->companySummary($company, 30),
]; ];
} }
@ -697,13 +705,18 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<flux:table.column>{{ __('Titel') }}</flux:table.column> <flux:table.column>{{ __('Titel') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column> <flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Datum') }}</flux:table.column> <flux:table.column>{{ __('Datum') }}</flux:table.column>
<flux:table.column align="end">{{ __('Aufrufe') }}</flux:table.column>
<flux:table.column align="end">{{ __('30 Tage') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column> <flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns> </flux:table.columns>
@forelse ($pressReleases as $pressRelease) @forelse ($pressReleases as $pressRelease)
<flux:table.row wire:key="company-pr-{{ $pressRelease->id }}"> <flux:table.row wire:key="company-pr-{{ $pressRelease->id }}">
<flux:table.cell> <flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $pressRelease->title }}</span> <a href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $pressRelease->title }}</a>
@if ($pressRelease->user_id !== auth()->id())
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('eingereicht über Partner') }}</span>
@endif
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
@php($statusValue = $pressRelease->status->value) @php($statusValue = $pressRelease->status->value)
@ -722,6 +735,12 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
{{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '–') }} {{ $pressRelease->published_at?->format('d.m.Y') ?? ($pressRelease->created_at?->format('d.m.Y') ?? '–') }}
</span> </span>
</flux:table.cell> </flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums font-semibold text-[color:var(--color-ink)]">{{ number_format((int) $pressRelease->hits, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell align="end">
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($recentViews[$pressRelease->id] ?? 0, 0, ',', '.') }}</span>
</flux:table.cell>
<flux:table.cell> <flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye" <flux:button size="sm" variant="filled" icon="eye"
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate> href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
@ -731,7 +750,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</flux:table.row> </flux:table.row>
@empty @empty
<flux:table.row> <flux:table.row>
<flux:table.cell colspan="4"> <flux:table.cell colspan="6">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center"> <div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3 <div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]"> bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
@ -778,34 +797,51 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</div> </div>
</article> </article>
{{-- ============== STATISTIK ============== --}} {{-- ============== STATISTIK (Roadmap 1c.3) ============== --}}
<article class="panel" id="statistik"> <article class="panel" id="statistik">
<div class="panel-head"> <div class="panel-head">
<span class="section-eyebrow">{{ __('Statistik') }}</span> <span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
<span class="badge warn">{{ __('Später') }}</span> <span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('alle Meldungen dieser Firma') }}</span>
</div> </div>
<div class="p-5 space-y-4"> <div class="p-5 space-y-4">
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Erste Kennzahlen zur Firma; detaillierte Auswertungen folgen später.') }}
</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3"> <div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]"> <div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressemitteilungen') }} {{ __('Aufrufe gesamt') }}
</div> </div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1"> <div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ $company->press_releases_count }} {{ number_format($summary['views_total'], 0, ',', '.') }}
</div> </div>
</div> </div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3"> <div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]"> <div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Pressekontakte') }} {{ __('Aufrufe 30 Tage') }}
</div> </div>
<div class="text-[18px] font-bold text-[color:var(--color-ink)] mt-1"> <div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ $company->contacts_count }} {{ number_format($summary['views_recent'], 0, ',', '.') }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Veröffentlicht') }}
</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">
{{ number_format($summary['published'], 0, ',', '.') }}
<span class="text-[11.5px] font-medium text-[color:var(--color-ink-3)]">/ {{ number_format($summary['releases'], 0, ',', '.') }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[10.5px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Zuletzt veröffentlicht') }}
</div>
<div class="text-[14px] font-bold text-[color:var(--color-ink)] mt-1.5">
{{ $summary['last_published_at']?->format('d.m.Y') ?? '–' }}
</div> </div>
</div> </div>
</div> </div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 leading-relaxed">
{{ __('Aufrufe sind Seitenaufrufe der Meldungen auf dem Portal. Der Verlauf je Meldung steht auf ihrer Detailseite.') }}
</p>
</div> </div>
</article> </article>
</div> </div>

View file

@ -1,8 +1,6 @@
<?php <?php
use App\Enums\BoostPlacement; use App\Enums\BoostPlacement;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType; use App\Enums\SinglePurchaseType;
@ -22,10 +20,12 @@ use App\Services\PressRelease\BoostService;
use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\QuotaExceededException;
use App\Services\PressRelease\VisibilityStats;
use Flux\Flux; use Flux\Flux;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component; use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
@ -37,6 +37,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public ?string $shareUrl = null; public ?string $shareUrl = null;
/** Zeitraum der Verlaufsgrafik „Sichtbarkeit" in Tagen (Roadmap 1c.1). */
#[Url(as: 'verlauf', except: 30)]
public int $viewRange = 30;
public ?string $shareExpiresAt = null; public ?string $shareExpiresAt = null;
public bool $depublicationConfirmed = false; public bool $depublicationConfirmed = false;
@ -784,15 +788,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$boostService = app(BoostService::class); $boostService = app(BoostService::class);
$proofService = app(ProofPdfService::class); $proofService = app(ProofPdfService::class);
// Share-Klicks der öffentlichen Detailseite, je Kanal aggregiert. // Sichtbarkeit belegen (Roadmap 1c.1): Rohzahlen und Tagesverlauf,
$shareCounts = $pr->events() // nur für Meldungen, die je online waren.
->where('type', PressReleaseEventType::Share) $stats = app(VisibilityStats::class);
->selectRaw('channel, count(*) as clicks') $wasPublic = $pr->published_at !== null || (int) $pr->hits > 0;
->groupBy('channel') $range = VisibilityStats::ranges()->contains($this->viewRange) ? $this->viewRange : 30;
->pluck('clicks', 'channel');
return [ return [
'pr' => $pr, 'pr' => $pr,
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
'viewRanges' => VisibilityStats::ranges(),
'publicLinks' => $pr->status === PressReleaseStatus::Published 'publicLinks' => $pr->status === PressReleaseStatus::Published
? $pr->publicPortalLinks() ? $pr->publicPortalLinks()
: [], : [],
@ -816,11 +822,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
// (Decision-Update §5.1, Schalter billing.enforce_booking). // (Decision-Update §5.1, Schalter billing.enforce_booking).
'hasActiveBooking' => $user->hasActiveBooking(), 'hasActiveBooking' => $user->hasActiveBooking(),
'latestRejection' => $latestRejection, 'latestRejection' => $latestRejection,
'shareTotal' => (int) $shareCounts->sum(),
'shareBreakdown' => $shareCounts
->sortDesc()
->map(fn (int $clicks, string $channel) => PressReleaseShareChannel::from($channel)->label().' '.number_format($clicks, 0, ',', '.'))
->implode(' · '),
'contacts' => $pr->contacts, 'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs, 'statusLogs' => $pr->statusLogs,
// T12: Depublikation ist config-gated (ohne Preis kein Angebot). // T12: Depublikation ist config-gated (ohne Preis kein Angebot).
@ -1375,21 +1376,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }} {{ $pr->published_at?->format('d.m.Y H:i') ?? '–' }}
</div> </div>
</div> </div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3"> @if ($visibility === null)
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div> <div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1"> <div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
{{ number_format($pr->hits, 0, ',', '.') }} <div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">{{ __('noch nicht veröffentlicht') }}</div>
</div> </div>
</div> @endif
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geteilt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($shareTotal, 0, ',', '.') }}
</div>
@if ($shareTotal > 0)
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ $shareBreakdown }}</div>
@endif
</div>
@if ($pr->scheduled_at) @if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3"> <div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div> <div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
@ -1483,6 +1475,109 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div> </div>
</article> </article>
{{-- ============== SICHTBARKEIT (Roadmap 1c.1) ============== --}}
@if ($visibility !== null)
@php
$measured = array_values(array_filter($dailyViews, fn ($day) => $day['views'] !== null));
$peak = max(1, (int) collect($measured)->max('views'));
$rangeSum = (int) collect($measured)->sum('views');
$barCount = count($dailyViews);
$chartWidth = 600;
$chartHeight = 96;
$gap = $barCount > 45 ? 1 : 2;
$barWidth = max(1, ($chartWidth - ($barCount - 1) * $gap) / max(1, $barCount));
@endphp
<article class="panel" id="sichtbarkeit">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
<div class="flex items-center gap-1">
@foreach ($viewRanges as $days)
<button type="button" wire:click="$set('viewRange', {{ $days }})"
@class([
'px-2 py-0.5 rounded-[4px] text-[11px] font-semibold transition-colors',
'bg-[color:var(--color-hub)] text-white' => $viewRange === $days,
'text-[color:var(--color-ink-3)] hover:bg-[color:var(--color-bg-elev)]' => $viewRange !== $days,
])>{{ __(':days Tage', ['days' => $days]) }}</button>
@endforeach
</div>
</div>
<div class="p-5 space-y-4">
<div class="grid gap-2 sm:grid-cols-3">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe gesamt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['hits'], 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('seit Veröffentlichung') }}</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe :days Tage', ['days' => $viewRange]) }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($rangeSum, 0, ',', '.') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">
@if ($visibility['last_view_at'])
{{ __('zuletzt :date', ['date' => $visibility['last_view_at']->format('d.m.Y')]) }}
@else
{{ __('noch kein gemessener Aufruf') }}
@endif
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geteilt') }}</div>
<div class="text-[18px] font-bold tabular-nums text-[color:var(--color-ink)] mt-1">{{ number_format($visibility['shares_total'], 0, ',', '.') }}</div>
@if ($visibility['shares'] !== [])
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ collect($visibility['shares'])->map(fn ($n, $label) => $label.' '.$n)->implode(' · ') }}</div>
@endif
</div>
</div>
<div>
<div class="flex items-baseline justify-between mb-2">
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe je Tag') }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('Spitze :n', ['n' => number_format($peak, 0, ',', '.')]) }}</span>
</div>
<svg viewBox="0 0 {{ $chartWidth }} {{ $chartHeight }}" class="w-full h-24 block" role="img" aria-label="{{ __('Aufrufe je Tag, letzte :days Tage', ['days' => $viewRange]) }}">
@foreach ($dailyViews as $i => $day)
@php
$x = $i * ($barWidth + $gap);
$h = $day['views'] === null ? 3 : max($day['views'] > 0 ? 2 : 1, round($day['views'] / $peak * ($chartHeight - 4)));
@endphp
<rect x="{{ $x }}" y="{{ $chartHeight - $h }}" width="{{ $barWidth }}" height="{{ $h }}" rx="1"
@class(['fill-[color:var(--color-hub)]' => $day['views'] !== null && $day['views'] > 0, 'fill-[color:var(--color-bg-rule)]' => $day['views'] === 0, 'fill-[color:var(--color-bg-rule)] opacity-40' => $day['views'] === null])>
<title>{{ $day['date']->format('d.m.Y') }}: {{ $day['views'] === null ? __('keine Messung') : trans_choice(':n Aufruf|:n Aufrufe', $day['views'], ['n' => $day['views']]) }}</title>
</rect>
@endforeach
</svg>
<div class="flex justify-between mt-1 text-[10.5px] tabular-nums text-[color:var(--color-ink-4)]">
<span>{{ $dailyViews[0]['date']->format('d.m.') }}</span>
<span>{{ __('heute') }}</span>
</div>
@if (collect($dailyViews)->contains(fn ($day) => $day['views'] === null))
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
{{ __('Blasse Tage liegen vor Beginn der Tageszählung (:date) — die Gesamtsumme enthält sie trotzdem.', ['date' => \Illuminate\Support\Carbon::parse((string) config('press_releases.events.recorded_since'))->format('d.m.Y')]) }}
</p>
@endif
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Auffindbarkeit') }}</div>
<div class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1">
@if ($visibility['in_sitemap_since'])
{{ __('In der Sitemap für Suchmaschinen seit :date.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }}
<span class="text-[color:var(--color-ink-4)]">{{ __('Ob und wann Google die Seite aufnimmt, entscheidet Google — der Link zeigt den aktuellen Stand.') }}</span>
@else
{{ __('Derzeit nicht öffentlich — nicht in der Sitemap.') }}
@endif
</div>
</div>
@if ($visibility['search_url'])
<flux:button size="sm" variant="filled" icon="magnifying-glass" href="{{ $visibility['search_url'] }}" target="_blank" rel="noopener">
{{ __('Bei Google prüfen') }}
</flux:button>
@endif
</div>
</div>
</article>
@endif
{{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}} {{-- Content-Score wie im Editor (gemeinsame Komponente, 13.07.). --}}
@if (! is_null($pr->content_score)) @if (! is_null($pr->content_score))
<x-content-score-panel :score="$pr->content_score" :tier="$pr->content_tier" /> <x-content-score-panel :score="$pr->content_score" :tier="$pr->content_tier" />

View file

@ -55,6 +55,7 @@
@csrf @csrf
{{-- Herkunft aus dem Mail-Link (?src=) bis auf das Token durchreichen. --}} {{-- Herkunft aus dem Mail-Link (?src=) bis auf das Token durchreichen. --}}
<input type="hidden" name="src" value="{{ request()->query('src') }}" /> <input type="hidden" name="src" value="{{ request()->query('src') }}" />
<input type="hidden" name="pr" value="{{ (int) request()->query('pr') ?: '' }}" />
{{-- Honeypot --}} {{-- Honeypot --}}
<div class="hidden" aria-hidden="true"> <div class="hidden" aria-hidden="true">

View file

@ -0,0 +1,158 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Models\User;
use App\Services\PressRelease\VisibilityStats;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Sichtbarkeit belegen (Roadmap 1c): Aufrufe je Firma und je Meldung als
* Rohzahlen (E2), sichtbar für jeden, der die Firma verwalten darf — auch
* für Meldungen, die ein Partner eingereicht hat.
*/
function claimant(): array
{
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH']);
$customer->companies()->attach($company->id, ['role' => 'member']);
test()->actingAs($customer);
return [$customer, $company];
}
function viewsOn(PressRelease $release, int $count, int $daysAgo = 0): void
{
// created_at ist nicht fillable — wie im Produktivpfad setzt es das
// Model; für die Zeitreihe wird es hier nachträglich verschoben.
foreach (range(1, $count) as $i) {
$event = PressReleaseEvent::query()->create(['press_release_id' => $release->id, 'type' => PressReleaseEventType::View, 'channel' => null]);
$event->forceFill(['created_at' => now()->subDays($daysAgo)->setTime(10, $i % 60)])->saveQuietly();
}
}
test('the company summary counts every release of the company, whoever submitted it', function () {
/** @var TestCase $this */
[, $company] = claimant();
$partner = User::factory()->create();
$old = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 120, 'published_at' => now()->subMonths(2)]);
$new = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 30, 'published_at' => now()->subDays(3)]);
PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $partner->id, 'status' => 'draft', 'hits' => 0]);
viewsOn($old, 5, daysAgo: 40);
viewsOn($old, 2, daysAgo: 2);
viewsOn($new, 7, daysAgo: 1);
$summary = app(VisibilityStats::class)->companySummary($company, 30);
expect($summary['releases'])->toBe(3)
->and($summary['published'])->toBe(2)
->and($summary['views_total'])->toBe(150)
->and($summary['views_recent'])->toBe(9)
->and($summary['last_published_at']->isSameDay($new->published_at))->toBeTrue();
expect(app(VisibilityStats::class)->recentViewsFor([$old->id, $new->id], 30))->toBe([$old->id => 2, $new->id => 7]);
});
test('the company page lists partner-submitted releases with their views', function () {
/** @var TestCase $this */
// Vor 1c.3 filterte die Seite nach user_id — ein Beansprucher sah
// „Keine Pressemitteilungen", obwohl connektar 68 eingereicht hatte.
[, $company] = claimant();
$partner = User::factory()->create();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
'company_id' => $company->id, 'user_id' => $partner->id, 'title' => 'Partnermeldung mit Reichweite', 'hits' => 4321, 'published_at' => now()->subDays(2),
]);
viewsOn($release, 3, daysAgo: 1);
LivewireVolt::test('customer.press-kits.show', ['id' => $company->id])
->assertSeeText('Partnermeldung mit Reichweite')
->assertSeeText('eingereicht über Partner')
->assertSeeText('4.321')
->assertSeeText('Aufrufe 30 Tage')
->assertDontSeeText('Keine Pressemitteilungen für diese Firma');
});
test('the daily series is gapless, marks days before measurement as unmeasured and respects retention', function () {
/** @var TestCase $this */
config()->set('press_releases.events.recorded_since', now()->subDays(10)->toDateString());
[, $company] = claimant();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDays(20)]);
viewsOn($release, 4, daysAgo: 3);
viewsOn($release, 1, daysAgo: 0);
$series = app(VisibilityStats::class)->dailyViews($release, 14);
expect($series)->toHaveCount(14)
->and($series[13]['views'])->toBe(1)
->and($series[10]['views'])->toBe(4)
->and($series[12]['views'])->toBe(0)
// Tage vor Beginn der Aufzeichnung: keine Messung, nicht 0.
->and($series[0]['views'])->toBeNull()
->and($series[2]['views'])->toBeNull()
// Der Starttag der Aufzeichnung selbst zählt schon.
->and($series[3]['views'])->toBe(0);
config()->set('press_releases.events.retention_days', 5);
expect(app(VisibilityStats::class)->dailyViews($release, 400))->toHaveCount(5);
});
test('the release summary reports sitemap presence and a search link only for public releases', function () {
/** @var TestCase $this */
[, $company] = claimant();
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]);
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'status' => 'draft']);
PressReleaseEvent::query()->create(['press_release_id' => $public->id, 'type' => PressReleaseEventType::Share, 'channel' => 'linkedin']);
viewsOn($public, 2, daysAgo: 1);
$stats = app(VisibilityStats::class);
$summary = $stats->releaseSummary($public);
expect($summary['hits'])->toBe(9)
->and($summary['shares_total'])->toBe(1)
->and($summary['in_sitemap_since']->isSameDay($public->published_at))->toBeTrue()
->and($summary['search_url'])->toContain('site%3A')
->and($summary['search_url'])->toContain($public->slug)
->and($summary['first_view_at'])->not->toBeNull();
$draftSummary = $stats->releaseSummary($draft);
expect($draftSummary['in_sitemap_since'])->toBeNull()
->and($draftSummary['search_url'])->toBeNull();
});
test('the release page shows the visibility panel with daily bars and the search link', function () {
/** @var TestCase $this */
[$customer, $company] = claimant();
$partner = User::factory()->create();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create([
'company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 777, 'published_at' => now()->subDays(5),
]);
viewsOn($release, 6, daysAgo: 2);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSeeText('Sichtbarkeit')
->assertSeeText('777')
->assertSeeText('Aufrufe 30 Tage')
->assertSeeText('Bei Google prüfen')
->assertSeeText('In der Sitemap für Suchmaschinen seit '.now()->subDays(5)->format('d.m.Y'))
->assertSee('<svg', false)
->set('viewRange', 90)
->assertSeeText('Aufrufe 90 Tage');
});
test('a draft shows no visibility panel', function () {
/** @var TestCase $this */
[$customer, $company] = claimant();
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'status' => 'draft', 'hits' => 0, 'published_at' => null]);
LivewireVolt::test('customer.press-releases.show', ['id' => $draft->id])
->assertDontSeeText('Bei Google prüfen')
->assertSeeText('noch nicht veröffentlicht');
});

View file

@ -182,7 +182,8 @@ test('the publish mail carries the manage hint at the bottom, tagged with its or
// E4 (15.09.2026): Hinweis in der Mail, unten, kein Angebot. // E4 (15.09.2026): Hinweis in der Mail, unten, kein Angebot.
expect($mail->manageNewsroomUrl)->toStartWith('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten?') expect($mail->manageNewsroomUrl)->toStartWith('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten?')
->and($mail->manageNewsroomUrl)->toContain('src='.ConversionSource::MailPublish->value) ->and($mail->manageNewsroomUrl)->toContain('src='.ConversionSource::MailPublish->value)
->and($mail->render())->toContain('Diesen Newsroom verwalten'); ->and($mail->manageNewsroomUrl)->toContain('pr='.$release->id)
->and($mail->render())->toContain('Diesen Newsroom verwalten und Aufrufe einsehen');
// Ohne Firma kein Hinweis — und nichts bricht. // Ohne Firma kein Hinweis — und nichts bricht.
$orphan = PressRelease::factory()->published()->create(['company_id' => null]); $orphan = PressRelease::factory()->published()->create(['company_id' => null]);
@ -201,3 +202,31 @@ test('a claim coming from the publish mail keeps the mail origin on the token',
expect($link->source)->toBe(ConversionSource::MailPublish->value) expect($link->source)->toBe(ConversionSource::MailPublish->value)
->and($link->payload['company_id'])->toBe($company->id); ->and($link->payload['company_id'])->toBe($company->id);
}); });
test('a claim from the publish mail lands on that release with its views, a foreign release id is ignored', function () {
/** @var TestCase $this */
// Roadmap 1c.2: der Link in der Veröffentlichungsmail trägt `pr`, die
// Claim-Seite reicht es durch, der Login landet auf der Meldung.
$company = claimCompany();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'language' => 'de']);
$foreign = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create();
$this->get(claimUrl().'?src=mail_publish&pr='.$release->id)
->assertOk()
->assertSee('name="pr" value="'.$release->id.'"', false);
$this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value, 'pr' => $release->id])->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->payload)->toBe(['company_id' => $company->id, 'press_release_id' => $release->id]);
$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-releases.show', ['id' => $release->id], absolute: false));
// Eine Meldung einer anderen Firma bleibt draußen — Landung auf der Firma.
RateLimiter::clear('newsroom-claim|ip|127.0.0.1');
$this->post(claimUrl(), ['pr' => $foreign->id])->assertSessionHas('newsroom-claim-status');
expect(MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail()->payload)->toBe(['company_id' => $company->id]);
});