diff --git a/app/Enums/PressReleaseEventType.php b/app/Enums/PressReleaseEventType.php new file mode 100644 index 00000000..0d4af7cf --- /dev/null +++ b/app/Enums/PressReleaseEventType.php @@ -0,0 +1,16 @@ + 'LinkedIn', + self::X => 'X', + self::Email => __('E-Mail'), + self::CopyLink => __('Link kopiert'), + }; + } +} diff --git a/app/Http/Controllers/Web/ReleaseShareTrackingController.php b/app/Http/Controllers/Web/ReleaseShareTrackingController.php new file mode 100644 index 00000000..45175eab --- /dev/null +++ b/app/Http/Controllers/Web/ReleaseShareTrackingController.php @@ -0,0 +1,45 @@ +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(); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index ae5b511c..8a8c94a4 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -183,6 +183,14 @@ class PressRelease extends Model 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) — * jeder Submit erzeugt einen neuen, unveränderlichen Eintrag. diff --git a/app/Models/PressReleaseEvent.php b/app/Models/PressReleaseEvent.php new file mode 100644 index 00000000..9580732a --- /dev/null +++ b/app/Models/PressReleaseEvent.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + public const ?string UPDATED_AT = null; + + protected $fillable = [ + 'press_release_id', + 'type', + 'channel', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => PressReleaseEventType::class, + 'channel' => PressReleaseShareChannel::class, + ]; + } + + /** + * @return BelongsTo + */ + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } +} diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index abfd33fa..6c911b44 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -2,6 +2,7 @@ namespace App\Services\Web; +use App\Enums\PressReleaseEventType; use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Support\WebPortalContext; @@ -64,6 +65,11 @@ class ReleaseDetailPageData ->whereKey($release->getKey()) ->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 = [ 'company', 'category.translations' => fn ($query) => $query->where('locale', $language), diff --git a/database/factories/PressReleaseEventFactory.php b/database/factories/PressReleaseEventFactory.php new file mode 100644 index 00000000..2c0162bf --- /dev/null +++ b/database/factories/PressReleaseEventFactory.php @@ -0,0 +1,35 @@ + + */ +class PressReleaseEventFactory extends Factory +{ + /** + * @return array + */ + 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()), + ]); + } +} diff --git a/database/migrations/2026_07_10_114915_create_press_release_events_table.php b/database/migrations/2026_07_10_114915_create_press_release_events_table.php new file mode 100644 index 00000000..c41fc81a --- /dev/null +++ b/database/migrations/2026_07_10_114915_create_press_release_events_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/dev/release/entwicklungsplan-release.md b/dev/release/entwicklungsplan-release.md index d411dd14..4e9578b3 100644 --- a/dev/release/entwicklungsplan-release.md +++ b/dev/release/entwicklungsplan-release.md @@ -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). **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/`. +**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:** 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.) diff --git a/lang/de/web.php b/lang/de/web.php index 59b162ff..2bc0089a 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -314,6 +314,7 @@ return [ 'audience' => 'Für Wirtschaftsjournalisten', 'time_at' => ':time Uhr', 'words' => ':count Wörter', + 'views' => ':count Aufrufe', 'recommended' => 'Empfehlung', 'recommended_hint' => 'Diese Pressemitteilung erreicht einen hohen Content-Score — basierend auf Quellenqualität, Verifizierungsstatus und inhaltlicher Relevanz.', 'photo' => 'Foto', diff --git a/lang/en/web.php b/lang/en/web.php index 8a6ef7c2..1ad93d98 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -309,6 +309,7 @@ return [ 'audience' => 'For business journalists', 'time_at' => ':time', 'words' => ':count words', + 'views' => ':count views', 'recommended' => 'Recommended', 'recommended_hint' => 'This press release achieves a high content score — based on source quality, verification status and content relevance.', 'photo' => 'Photo', diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 4983e242..8743b3ca 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1,5 +1,7 @@ events() + ->where('type', PressReleaseEventType::Share) + ->selectRaw('channel, count(*) as clicks') + ->groupBy('channel') + ->pluck('clicks', 'channel'); + return [ 'pr' => $pr, '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). 'hasActiveBooking' => $user->hasActiveBooking(), '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, 'statusLogs' => $pr->statusLogs, // 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, ',', '.') }} +
+
{{ __('Geteilt') }}
+
+ {{ number_format($shareTotal, 0, ',', '.') }} +
+ @if ($shareTotal > 0) +
{{ $shareBreakdown }}
+ @endif +
@if ($pr->scheduled_at)
{{ __('Geplante Veröffentlichung') }}
diff --git a/routes/web.php b/routes/web.php index 59bbaf35..ca8cc449 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\ReleaseDetailController; +use App\Http\Controllers\Web\ReleaseShareTrackingController; use App\Http\Controllers\Web\ReportFormController; use App\Http\Controllers\Web\SitemapController; 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) 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) Route::get('preise', PreiseController::class)->name('preise'); diff --git a/tests/Feature/Web/PressReleaseTrackingTest.php b/tests/Feature/Web/PressReleaseTrackingTest.php new file mode 100644 index 00000000..f1125ab7 --- /dev/null +++ b/tests/Feature/Web/PressReleaseTrackingTest.php @@ -0,0 +1,142 @@ +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); +});