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:
parent
a00f9dc79a
commit
3ef1b84600
14 changed files with 676 additions and 59 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
196
app/Services/PressRelease/VisibilityStats.php
Normal file
196
app/Services/PressRelease/VisibilityStats.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
158
tests/Feature/VisibilityStatsTest.php
Normal file
158
tests/Feature/VisibilityStatsTest.php
Normal 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');
|
||||||
|
});
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue