PM-Tracking: Ereignis-Log fuer Aufrufe + Shares (press_release_events)

- press_release_events (type view/share, channel, ohne Personenbezug):
  Model/Enums/Factory, Cascade-Delete an der PM
- Detailaufruf loggt View-Event zusaetzlich zum Legacy-hits-Zaehler
  (bleibt bewusst einfacher Page-Request-Zaehler, User-Vorgabe 10.07.)
- POST press-release/{slug}/share (throttle 30/min, nur archiv-sichtbare
  PMs des Portals) loggt Teilen-Klicks je Kanal (LinkedIn/X/E-Mail/Copy)
- Kundenpanel-PM-Seite: neue Kachel Geteilt mit Kanal-Aufschluesselung
- lang: web.detail.views (de/en)
- Tests: Web/PressReleaseTrackingTest (7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-10 09:56:42 +00:00
parent 80a37f8fcd
commit 644f860223
14 changed files with 392 additions and 0 deletions

View file

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
/**
* Art eines getrackten Ereignisses an einer Pressemitteilung.
*
* View-Events entstehen serverseitig beim Aufruf der öffentlichen
* Detailseite (zusätzlich zum Legacy-Zähler `press_releases.hits`),
* Share-Events beim Klick auf die Teilen-Buttons der Detailseite.
*/
enum PressReleaseEventType: string
{
case View = 'view';
case Share = 'share';
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Enums;
/**
* Teilen-Kanäle der öffentlichen PM-Detailseite (Share-Buttons).
*/
enum PressReleaseShareChannel: string
{
case LinkedIn = 'linkedin';
case X = 'x';
case Email = 'email';
case CopyLink = 'copy';
public function label(): string
{
return match ($this) {
self::LinkedIn => 'LinkedIn',
self::X => 'X',
self::Email => __('E-Mail'),
self::CopyLink => __('Link kopiert'),
};
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
/**
* Nimmt Share-Klicks der öffentlichen PM-Detailseite entgegen und loggt
* sie als Ereignis (Kanal: LinkedIn/X/E-Mail/Link kopiert) — ohne
* Personenbezug. Nur für im Archiv sichtbare PMs des aufrufenden Portals.
*/
class ReleaseShareTrackingController extends Controller
{
public function __invoke(Request $request, string $edition, string $slug): Response
{
$validated = $request->validate([
'channel' => ['required', Rule::enum(PressReleaseShareChannel::class)],
]);
$context = WebPortalContext::fromRequest();
$release = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->where('slug', $slug)
->firstOrFail();
$release->events()->create([
'type' => PressReleaseEventType::Share,
'channel' => $validated['channel'],
]);
return response()->noContent();
}
}

View file

@ -183,6 +183,14 @@ class PressRelease extends Model
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
} }
/**
* Ereignis-Log (Aufrufe/Shares) — Aufruf-Gesamtzähler bleibt `hits`.
*/
public function events(): HasMany
{
return $this->hasMany(PressReleaseEvent::class);
}
/** /**
* Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) — * Versionierter Audit-Trail der KI-Deklarationen (Art. 50 KI-VO) —
* jeder Submit erzeugt einen neuen, unveränderlichen Eintrag. * jeder Submit erzeugt einen neuen, unveränderlichen Eintrag.

View file

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use Database\Factories\PressReleaseEventFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Einzelnes getracktes Ereignis an einer Pressemitteilung (Aufruf/Share).
* Reines Append-Log ohne Personenbezug; `channel` ist nur bei Shares gesetzt.
*/
class PressReleaseEvent extends Model
{
/** @use HasFactory<PressReleaseEventFactory> */
use HasFactory;
public const ?string UPDATED_AT = null;
protected $fillable = [
'press_release_id',
'type',
'channel',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => PressReleaseEventType::class,
'channel' => PressReleaseShareChannel::class,
];
}
/**
* @return BelongsTo<PressRelease, $this>
*/
public function pressRelease(): BelongsTo
{
return $this->belongsTo(PressRelease::class);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\Web; namespace App\Services\Web;
use App\Enums\PressReleaseEventType;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Support\WebPortalContext; use App\Support\WebPortalContext;
@ -64,6 +65,11 @@ class ReleaseDetailPageData
->whereKey($release->getKey()) ->whereKey($release->getKey())
->update(['hits' => ($release->hits ?? 0) + 1]); ->update(['hits' => ($release->hits ?? 0) + 1]);
// Zusätzlich zum Legacy-Zähler als Einzel-Ereignis loggen (ohne
// Personenbezug); Anzeige auf der Seite inkl. des aktuellen Aufrufs.
$release->events()->create(['type' => PressReleaseEventType::View]);
$release->hits = ($release->hits ?? 0) + 1;
$with = [ $with = [
'company', 'company',
'category.translations' => fn ($query) => $query->where('locale', $language), 'category.translations' => fn ($query) => $query->where('locale', $language),

View file

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PressReleaseEvent>
*/
class PressReleaseEventFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'press_release_id' => PressRelease::factory(),
'type' => PressReleaseEventType::View,
'channel' => null,
];
}
public function share(?PressReleaseShareChannel $channel = null): static
{
return $this->state([
'type' => PressReleaseEventType::Share,
'channel' => $channel ?? fake()->randomElement(PressReleaseShareChannel::cases()),
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ereignis-Log je Pressemitteilung (Aufrufe, Shares).
*
* Bewusst schmal und ohne Personenbezug (keine IP, kein User-Agent):
* `hits` auf press_releases bleibt der angezeigte Aufruf-Gesamtzähler
* (inkl. Legacy-Bestand); dieses Log ergänzt ihn um auswertbare
* Einzel-Ereignisse mit Zeitstempel und Kanal.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('press_release_events', function (Blueprint $table) {
$table->id();
$table->foreignId('press_release_id')->constrained()->cascadeOnDelete();
$table->string('type', 20);
$table->string('channel', 20)->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['press_release_id', 'type', 'channel'], 'pre_release_type_channel_idx');
$table->index(['press_release_id', 'type', 'created_at'], 'pre_release_type_created_idx');
});
}
public function down(): void
{
Schema::dropIfExists('press_release_events');
}
};

View file

@ -26,6 +26,8 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G
**08.07. — Agentur-Attribution + Depublikations-Kauf + eVatR-USt-ID-Pruefung:** Agentur-Attribution neu geloest — Uebermittler kommt aus neuem Profilfeld `users.agency_name` statt eigener Agentur-Firma; PM-Detailseite zeigt Urheber/Uebermittler-Zeile; `legacy:migrate-agency-names` uebertraegt die Alt-Agenturnamen; `companies:retire-agency-type` schafft den Firmen-Typ „Agentur" ab; Firmen-Typ-Auswahl aus der Profil-UI entfernt (Plan abgehakt, Abnahme 08.07. dokumentiert). **Depublikation end-to-end:** Kauf wird als eingeloest markiert, erzeugt Rechnung + Bestaetigungs-Mail; Buchungsseiten-Verlauf zeigt den Kauftyp, Erfolgsmeldungen gruen. **USt-ID-Pruefung (neu):** vor jeder Buchung per eVatR (EU-VIES) gegengeprueft, Stripe-Tax-IDs abgeglichen; eigene USt-IdNr. aus `BILLING_OWN_VAT_ID`. Deploy-Runbook §3a ergaenzt: FULLTEXT-Migrations-Wartungsfenster fuer `press_releases` (bei importiertem Bestand faellt jede Spaltenaenderung auf ALGORITHM=COPY = Stunden; Ablauf Indizes droppen -> migrieren -> nacheinander neu bauen = ~6 Min). **08.07. — Agentur-Attribution + Depublikations-Kauf + eVatR-USt-ID-Pruefung:** Agentur-Attribution neu geloest — Uebermittler kommt aus neuem Profilfeld `users.agency_name` statt eigener Agentur-Firma; PM-Detailseite zeigt Urheber/Uebermittler-Zeile; `legacy:migrate-agency-names` uebertraegt die Alt-Agenturnamen; `companies:retire-agency-type` schafft den Firmen-Typ „Agentur" ab; Firmen-Typ-Auswahl aus der Profil-UI entfernt (Plan abgehakt, Abnahme 08.07. dokumentiert). **Depublikation end-to-end:** Kauf wird als eingeloest markiert, erzeugt Rechnung + Bestaetigungs-Mail; Buchungsseiten-Verlauf zeigt den Kauftyp, Erfolgsmeldungen gruen. **USt-ID-Pruefung (neu):** vor jeder Buchung per eVatR (EU-VIES) gegengeprueft, Stripe-Tax-IDs abgeglichen; eigene USt-IdNr. aus `BILLING_OWN_VAT_ID`. Deploy-Runbook §3a ergaenzt: FULLTEXT-Migrations-Wartungsfenster fuer `press_releases` (bei importiertem Bestand faellt jede Spaltenaenderung auf ALGORITHM=COPY = Stunden; Ablauf Indizes droppen -> migrieren -> nacheinander neu bauen = ~6 Min).
**09.07. — KI-Kennzeichnung (Art. 50 KI-VO) + PM-Editor-UX + Tages-Fixes (Suite: 1142 gruen):** KI-Kennzeichnung als **Pflicht-Deklaration fuer Text und Bilder** umgesetzt (Enums `AiTextDeclaration`/`AiImageDeclaration`, Model `PressReleaseAiDeclaration` + Migrations, Concern `InteractsWithAiDeclaration`, `AiDeclarationRequiredException`, oeffentliches Label `portal/ai-public-label`, Test `PressReleaseAiDeclarationTest`) — EU-KI-VO Art. 50 Transparenzpflicht. PM-Editor-UX: Kategorie in die Schreibflaeche, Bild-Upload als Modal-Flow. **Tages-Fixes:** OpenAPI-Route `/docs/api/v1` auf `public/api/v1.yml` umgebogen (der `docs/`-Ordner ist ein nicht mitgekommener Mount; Route lief sonst 500) + 404-Guard; `depublish`-Credits `25 -> 35` (Entscheidung O6) in config bestaetigt und die zwei stehen gebliebenen Preis-Tests nachgezogen. Suite wieder komplett gruen (1142). **09.07. — KI-Kennzeichnung (Art. 50 KI-VO) + PM-Editor-UX + Tages-Fixes (Suite: 1142 gruen):** KI-Kennzeichnung als **Pflicht-Deklaration fuer Text und Bilder** umgesetzt (Enums `AiTextDeclaration`/`AiImageDeclaration`, Model `PressReleaseAiDeclaration` + Migrations, Concern `InteractsWithAiDeclaration`, `AiDeclarationRequiredException`, oeffentliches Label `portal/ai-public-label`, Test `PressReleaseAiDeclarationTest`) — EU-KI-VO Art. 50 Transparenzpflicht. PM-Editor-UX: Kategorie in die Schreibflaeche, Bild-Upload als Modal-Flow. **Tages-Fixes:** OpenAPI-Route `/docs/api/v1` auf `public/api/v1.yml` umgebogen (der `docs/`-Ordner ist ein nicht mitgekommener Mount; Route lief sonst 500) + 404-Guard; `depublish`-Credits `25 -> 35` (Entscheidung O6) in config bestaetigt und die zwei stehen gebliebenen Preis-Tests nachgezogen. Suite wieder komplett gruen (1142).
**09.07. abends — R5.3 Monitoring + Buchungsdaten + kompletter WIP in 7 Commits committet (Suite: 1149 gruen):** (a) **R5.3 Monitoring** umgesetzt (Details in R5.3): `OperationalAlert` (immer `Log::critical` + Best-Effort-Mail an `MONITORING_ALERT_EMAIL`), alle 6 Scheduler-Tasks mit `->onFailure()`, Queue-`failing`-Listener, `LOG_STACK=daily` als Prod-Soll. (b) **Buchungs-/Verlaengerungsdatum** des Abos auf der Buchungsseite (`SubscriptionPeriodResolver` holt `start_date` + `current_period_end` gecacht aus Stripe, da Cashier die Periode nicht lokal spiegelt). (c) Den kompletten bis dahin uncommitteten Arbeitsbaum in **7 fokussierte Commits** zerlegt (Branch jetzt **~187 vor `main`**): OpenAPI-Route auf `public/api/v1.yml` (500-Fix) · Depublish-Credit 35 · Buchungs-/Verlaengerungsdatum · R5.3 Monitoring · Release-Doku · Backend-WIP (Kontakt-Queue, Page-Cache, Validierungs-Feedback, Company-Portal-Restriction, i18n-Auslagerung) · Web-Frontend-Umbau (separat, User-Bereich; Kontakt-Modal durch `contact-launcher` ersetzt). **Bewusst uncommittet belassen:** `sidebar.blade.php` (User-Regel, aus allen Commits ausgeschlossen), der geloeschte `docs/`-Mount + `config/pressekonto.code-workspace` (Mount nicht mitgekommen — die einzige code-relevante Datei `docs/api/v1.yml` liegt jetzt als `public/api/v1.yml` vor), zwei Dev-Screenshots in `dev/frontend/booking/`. **09.07. abends — R5.3 Monitoring + Buchungsdaten + kompletter WIP in 7 Commits committet (Suite: 1149 gruen):** (a) **R5.3 Monitoring** umgesetzt (Details in R5.3): `OperationalAlert` (immer `Log::critical` + Best-Effort-Mail an `MONITORING_ALERT_EMAIL`), alle 6 Scheduler-Tasks mit `->onFailure()`, Queue-`failing`-Listener, `LOG_STACK=daily` als Prod-Soll. (b) **Buchungs-/Verlaengerungsdatum** des Abos auf der Buchungsseite (`SubscriptionPeriodResolver` holt `start_date` + `current_period_end` gecacht aus Stripe, da Cashier die Periode nicht lokal spiegelt). (c) Den kompletten bis dahin uncommitteten Arbeitsbaum in **7 fokussierte Commits** zerlegt (Branch jetzt **~187 vor `main`**): OpenAPI-Route auf `public/api/v1.yml` (500-Fix) · Depublish-Credit 35 · Buchungs-/Verlaengerungsdatum · R5.3 Monitoring · Release-Doku · Backend-WIP (Kontakt-Queue, Page-Cache, Validierungs-Feedback, Company-Portal-Restriction, i18n-Auslagerung) · Web-Frontend-Umbau (separat, User-Bereich; Kontakt-Modal durch `contact-launcher` ersetzt). **Bewusst uncommittet belassen:** `sidebar.blade.php` (User-Regel, aus allen Commits ausgeschlossen), der geloeschte `docs/`-Mount + `config/pressekonto.code-workspace` (Mount nicht mitgekommen — die einzige code-relevante Datei `docs/api/v1.yml` liegt jetzt als `public/api/v1.yml` vor), zwei Dev-Screenshots in `dev/frontend/booking/`.
**10.07. — PM-Tracking (User-Einschub, `docs/weiteres/Pm Tracken.md`):** Ereignis-Log `press_release_events` (type view/share, channel, ohne Personenbezug) + Model/Enums/Factory. Aufrufe: Legacy-`hits`-Zaehler bleibt die angezeigte Wahrheit (bewusst einfacher Page-Request-Zaehler, User-Vorgabe), zusaetzlich View-Event je Detailaufruf; oeffentliche Detailseite zeigt Aufrufe wieder in der Meta-Zeile (wie im Altsystem, beide Hero-Varianten, nicht in der Vorschau). Shares: `POST …/press-release/{slug}/share` (throttle 30/min, nur archiv-sichtbare PMs) loggt LinkedIn/X/E-Mail/Link-kopiert-Klicks der Teilen-Buttons (fetch keepalive + CSRF-Meta); Kundenpanel-PM-Seite zeigt neue Kachel „Geteilt" mit Gesamtzahl + Kanal-Aufschluesselung. Tests: `Web/PressReleaseTrackingTest` (7). Hinweis: Branch war zu Sessionbeginn bereits vollstaendig gepusht; die „drei user_filter_presets-Migrations" (R6.5 optional) existieren nicht mehr — es gibt nur noch eine konsolidierte, Punkt gegenstandslos.
**Wiedereinstieg / naechste Schritte:** **Wiedereinstieg / naechste Schritte:**
1. ~~Klaerungsliste Rechtstexte~~ **erledigt 06.07.**; ~~R3.1-Restbacklog~~ **erledigt 06.07.** (siehe oben). 1. ~~Klaerungsliste Rechtstexte~~ **erledigt 06.07.**; ~~R3.1-Restbacklog~~ **erledigt 06.07.** (siehe oben).
2. **[EXTERN]:** Postfach `info@pressekonto.com` anlegen (Haupt-Rechts-Mail, 06.07.); CONTACT_EMAIL_*-Support-Postfaecher je Brand; REPORT_EMAIL_* optional · R1.2 Stripe-Live · R1.4 Preisfreigabe · R5.4 Favicons · anwaltliche Freigabe AGB v0.6/Datenschutz v0.3. (~~Depublikations-Preis~~ entschieden: 35 EUR netto.) 2. **[EXTERN]:** Postfach `info@pressekonto.com` anlegen (Haupt-Rechts-Mail, 06.07.); CONTACT_EMAIL_*-Support-Postfaecher je Brand; REPORT_EMAIL_* optional · R1.2 Stripe-Live · R1.4 Preisfreigabe · R5.4 Favicons · anwaltliche Freigabe AGB v0.6/Datenschutz v0.3. (~~Depublikations-Preis~~ entschieden: 35 EUR netto.)

View file

@ -314,6 +314,7 @@ return [
'audience' => 'Für Wirtschaftsjournalisten', 'audience' => 'Für Wirtschaftsjournalisten',
'time_at' => ':time Uhr', 'time_at' => ':time Uhr',
'words' => ':count Wörter', 'words' => ':count Wörter',
'views' => ':count Aufrufe',
'recommended' => 'Empfehlung', 'recommended' => 'Empfehlung',
'recommended_hint' => 'Diese Pressemitteilung erreicht einen hohen Content-Score — basierend auf Quellenqualität, Verifizierungsstatus und inhaltlicher Relevanz.', 'recommended_hint' => 'Diese Pressemitteilung erreicht einen hohen Content-Score — basierend auf Quellenqualität, Verifizierungsstatus und inhaltlicher Relevanz.',
'photo' => 'Foto', 'photo' => 'Foto',

View file

@ -309,6 +309,7 @@ return [
'audience' => 'For business journalists', 'audience' => 'For business journalists',
'time_at' => ':time', 'time_at' => ':time',
'words' => ':count words', 'words' => ':count words',
'views' => ':count views',
'recommended' => 'Recommended', 'recommended' => 'Recommended',
'recommended_hint' => 'This press release achieves a high content score — based on source quality, verification status and content relevance.', 'recommended_hint' => 'This press release achieves a high content score — based on source quality, verification status and content relevance.',
'photo' => 'Photo', 'photo' => 'Photo',

View file

@ -1,5 +1,7 @@
<?php <?php
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;
@ -219,6 +221,13 @@ 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.
$shareCounts = $pr->events()
->where('type', PressReleaseEventType::Share)
->selectRaw('channel, count(*) as clicks')
->groupBy('channel')
->pluck('clicks', 'channel');
return [ return [
'pr' => $pr, 'pr' => $pr,
'publicLinks' => $pr->status === PressReleaseStatus::Published 'publicLinks' => $pr->status === PressReleaseStatus::Published
@ -246,6 +255,11 @@ 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).
@ -759,6 +773,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ number_format($pr->hits, 0, ',', '.') }} {{ number_format($pr->hits, 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="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>

View file

@ -10,6 +10,7 @@ use App\Http\Controllers\Web\NewsroomDetailController;
use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\NewsroomsController;
use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\PreiseController;
use App\Http\Controllers\Web\ReleaseDetailController; use App\Http\Controllers\Web\ReleaseDetailController;
use App\Http\Controllers\Web\ReleaseShareTrackingController;
use App\Http\Controllers\Web\ReportFormController; use App\Http\Controllers\Web\ReportFormController;
use App\Http\Controllers\Web\SitemapController; use App\Http\Controllers\Web\SitemapController;
use App\Http\Controllers\Web\SucheController; use App\Http\Controllers\Web\SucheController;
@ -205,6 +206,12 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
// Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution) // Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution)
Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail'); Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail');
// Share-Tracking der Detailseite (Teilen-Buttons): loggt Kanal-Klicks
// als press_release_events, ohne Personenbezug.
Route::post('press-release/{slug}/share', ReleaseShareTrackingController::class)
->middleware('throttle:30,1')
->name('release.share');
// Preise / Tarife (datengetrieben: Plan + config/billing + config/credits) // Preise / Tarife (datengetrieben: Plan + config/billing + config/credits)
Route::get('preise', PreiseController::class)->name('preise'); Route::get('preise', PreiseController::class)->name('preise');

View file

@ -0,0 +1,142 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* @return PressRelease im Archiv sichtbare PM auf businessportal24
*/
function trackingRelease(array $attributes = []): PressRelease
{
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
return PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(array_merge(['slug' => 'tracking-pm', 'published_at' => now()->subDay()], $attributes));
}
test('release detail logs a view event alongside the hit counter', function () {
/** @var TestCase $this */
$release = trackingRelease(['hits' => 4]);
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertSuccessful();
expect(PressRelease::withoutGlobalScopes()->find($release->id)->hits)->toBe(5);
$event = PressReleaseEvent::query()->sole();
expect($event->press_release_id)->toBe($release->id)
->and($event->type)->toBe(PressReleaseEventType::View)
->and($event->channel)->toBeNull();
});
test('release detail shows the public view count', function () {
/** @var TestCase $this */
$release = trackingRelease(['hits' => 1233]);
// Anzeige inkl. des aktuellen Aufrufs (1234).
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
->assertSuccessful()
->assertSeeText('1.234 Aufrufe');
});
test('share endpoint logs a share event with its channel', function () {
/** @var TestCase $this */
$release = trackingRelease();
$this->postJson('https://businessportal24.test/de/press-release/'.$release->slug.'/share', [
'channel' => 'linkedin',
])->assertNoContent();
$event = PressReleaseEvent::query()->sole();
expect($event->press_release_id)->toBe($release->id)
->and($event->type)->toBe(PressReleaseEventType::Share)
->and($event->channel)->toBe(PressReleaseShareChannel::LinkedIn);
});
test('share endpoint rejects unknown channels', function () {
/** @var TestCase $this */
$release = trackingRelease();
$this->postJson('https://businessportal24.test/de/press-release/'.$release->slug.'/share', [
'channel' => 'myspace',
])->assertUnprocessable();
expect(PressReleaseEvent::query()->count())->toBe(0);
});
test('share endpoint returns 404 for drafts and unknown slugs', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$draft = PressRelease::factory()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(['status' => PressReleaseStatus::Draft, 'slug' => 'entwurf-pm']);
$this->postJson('https://businessportal24.test/de/press-release/'.$draft->slug.'/share', [
'channel' => 'x',
])->assertNotFound();
$this->postJson('https://businessportal24.test/de/press-release/gibt-es-nicht/share', [
'channel' => 'x',
])->assertNotFound();
expect(PressReleaseEvent::query()->count())->toBe(0);
});
test('customer panel shows view and share stats for the owner', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$release = PressRelease::factory()->published()->create([
'user_id' => $customer->id,
'company_id' => $company->id,
'category_id' => Category::factory()->create()->id,
'portal' => $company->portal->value,
'hits' => 42,
]);
PressReleaseEvent::factory()->share(PressReleaseShareChannel::LinkedIn)->for($release)->count(2)->create();
PressReleaseEvent::factory()->share(PressReleaseShareChannel::CopyLink)->for($release)->create();
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSee('Aufrufe')
->assertSee('42')
->assertSee('Geteilt')
->assertSee('LinkedIn 2')
->assertSee('Link kopiert 1');
});
test('deleting a press release removes its tracked events', function () {
$release = trackingRelease();
PressReleaseEvent::factory()->for($release)->create();
PressReleaseEvent::factory()->share(PressReleaseShareChannel::X)->for($release)->create();
PressRelease::withoutGlobalScopes()->find($release->id)->forceDelete();
expect(PressReleaseEvent::query()->count())->toBe(0);
});