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:
parent
80a37f8fcd
commit
644f860223
14 changed files with 392 additions and 0 deletions
16
app/Enums/PressReleaseEventType.php
Normal file
16
app/Enums/PressReleaseEventType.php
Normal 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';
|
||||
}
|
||||
24
app/Enums/PressReleaseShareChannel.php
Normal file
24
app/Enums/PressReleaseShareChannel.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/ReleaseShareTrackingController.php
Normal file
45
app/Http/Controllers/Web/ReleaseShareTrackingController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
47
app/Models/PressReleaseEvent.php
Normal file
47
app/Models/PressReleaseEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
35
database/factories/PressReleaseEventFactory.php
Normal file
35
database/factories/PressReleaseEventFactory.php
Normal 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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseEventType;
|
||||
use App\Enums\PressReleaseShareChannel;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\SinglePurchaseType;
|
||||
|
|
@ -219,6 +221,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$boostService = app(BoostService::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 [
|
||||
'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, ',', '.') }}
|
||||
</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)
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
142
tests/Feature/Web/PressReleaseTrackingTest.php
Normal file
142
tests/Feature/Web/PressReleaseTrackingTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue