Admin ▸ Pressemitteilung zeigt laufende Boosts; „Boost beenden“ verlangt einen Grund und billing:manage, beendet laufende und angeschlossene Zeiträume, verwirft die Seiten-Caches und protokolliert „Boost beendet“. Credits werden nicht zurückgebucht. audit:admins listet alle Konten mit Rechten über den Kundenbereich hinaus (Super-Admin, Rollen außer customer/api-only, direkte Permissions), auch gesperrte und gelöschte, als Markdown mit 2FA, Passkeys, Tokens und letztem Sitzungswiderruf. E-Mail standardmäßig nur mit Domain (Nachweis BN-02). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
12 KiB
PHP
317 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\ActivityType;
|
||
use App\Enums\BoostPlacement;
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseClassification;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Exceptions\BoostNotAllowedException;
|
||
use App\Models\Boost;
|
||
use App\Models\PressRelease;
|
||
use App\Models\User;
|
||
use App\Scopes\PortalScope;
|
||
use App\Services\ActivityLog;
|
||
use App\Services\Billing\CreditPricingService;
|
||
use App\Services\Billing\CreditWalletService;
|
||
use App\Support\WebPageCache;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Boost-Kauf laut Decision-Update §2.2 + Platzierungs-Entscheidung 13.07.2026.
|
||
* Gate: nur veröffentlichte, grüne PMs. Bezahlung über die Credit-Wallet,
|
||
* Preis nach Laufzeit (7/14/30 Tage) und Stufe: Startseiten-Boost (Startseite
|
||
* + Branchenseite) bzw. Kategorie-Boost (nur Branchenseite, halber Satz).
|
||
* Slot-Inventar je Ausgabe (config credits.boost_slots): mehr gleichzeitige
|
||
* Boosts als sichtbare Plätze werden nicht verkauft — der Kauf wirft dann
|
||
* BoostNotAllowedException::soldOut() mit dem frühesten freien Termin.
|
||
* Mehrfachkauf derselben Stufe verlängert: der neue Zeitraum schließt an ein
|
||
* laufendes Ende an und braucht keinen freien Slot (die PM hält ihren).
|
||
*/
|
||
class BoostService
|
||
{
|
||
public function __construct(
|
||
private readonly CreditPricingService $pricing,
|
||
private readonly CreditWalletService $wallet,
|
||
private readonly ActivityLog $activityLog,
|
||
) {}
|
||
|
||
/**
|
||
* Boostbar = veröffentlicht UND grün klassifiziert (gelb/rot nicht).
|
||
*/
|
||
public function canBoost(PressRelease $pressRelease): bool
|
||
{
|
||
return $pressRelease->status === PressReleaseStatus::Published
|
||
&& $pressRelease->classification === PressReleaseClassification::Green;
|
||
}
|
||
|
||
public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool
|
||
{
|
||
return $pressRelease->boosts()
|
||
->active()
|
||
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
|
||
->exists();
|
||
}
|
||
|
||
/**
|
||
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`),
|
||
* optional je Stufe, oder null, wenn die PM gerade nicht geboostet ist.
|
||
*/
|
||
public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon
|
||
{
|
||
$endsAt = $pressRelease->boosts()
|
||
->active()
|
||
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
|
||
->max('ends_at');
|
||
|
||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||
}
|
||
|
||
/**
|
||
* Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer
|
||
* verfügbar (die PM hält ihren Slot); sonst muss auf jeder Ausgabe, auf
|
||
* der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je
|
||
* Rubrik), ein Slot frei sein. `free` zählt die noch freien Plätze
|
||
* (knappste Ausgabe, ohne einen eigenen gehaltenen Slot); `freeAt` nennt
|
||
* bei „ausgebucht" den frühesten Zeitpunkt, zu dem ein Platz frei wird.
|
||
*
|
||
* @return array{available: bool, free: int, limit: int, freeAt: Carbon|null}
|
||
*/
|
||
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
|
||
{
|
||
$limit = $this->slotLimit($placement);
|
||
$holdsSlot = $this->isBoosted($pressRelease, $placement);
|
||
$free = $limit;
|
||
$freeAt = null;
|
||
|
||
foreach ($this->affectedPortals($pressRelease) as $portal) {
|
||
// Je Konkurrent zählt seine PM einen Slot, gehalten bis zum
|
||
// spätesten Boost-Ende (Verlängerungen schließen lückenlos an).
|
||
$heldUntil = Boost::query()
|
||
->forPlacement($placement)
|
||
->active()
|
||
->where('press_release_id', '!=', $pressRelease->id)
|
||
->whereHas('pressRelease', function (Builder $query) use ($portal, $pressRelease, $placement): void {
|
||
$query
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereIn('portal', [$portal, Portal::Both->value])
|
||
->where('language', $pressRelease->language)
|
||
->when(
|
||
$placement === BoostPlacement::Category,
|
||
fn (Builder $query) => $query->where('category_id', $pressRelease->category_id),
|
||
);
|
||
})
|
||
->groupBy('press_release_id')
|
||
->selectRaw('press_release_id, MAX(ends_at) AS held_until')
|
||
->pluck('held_until');
|
||
|
||
$free = min($free, max(0, $limit - $heldUntil->count()));
|
||
|
||
if ($holdsSlot || $heldUntil->count() < $limit) {
|
||
continue;
|
||
}
|
||
|
||
// Diese Ausgabe ist voll: frei, sobald der zuerst endende
|
||
// Konkurrent seinen Slot räumt. Bei mehreren vollen Ausgaben
|
||
// zählt die späteste (die PM braucht überall einen Platz).
|
||
$portalFreeAt = Carbon::parse($heldUntil->min());
|
||
$freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt);
|
||
}
|
||
|
||
return [
|
||
'available' => $holdsSlot || $free > 0,
|
||
'free' => $free,
|
||
'limit' => $limit,
|
||
'freeAt' => $freeAt,
|
||
];
|
||
}
|
||
|
||
public function slotLimit(BoostPlacement $placement): int
|
||
{
|
||
return (int) config("credits.boost_slots.{$placement->value}");
|
||
}
|
||
|
||
/**
|
||
* Bucht einen Boost der angegebenen Stufe. Wirft BoostNotAllowedException
|
||
* am Gate (nicht boostbar bzw. Slots ausgebucht) und
|
||
* InvalidArgumentException bei unbekannter Laufzeit; die Wallet-Belastung
|
||
* wirft InsufficientCreditsException, falls das Guthaben nicht reicht.
|
||
*/
|
||
public function boost(User $user, PressRelease $pressRelease, int $days, BoostPlacement $placement = BoostPlacement::Home): Boost
|
||
{
|
||
if (! $this->canBoost($pressRelease)) {
|
||
throw BoostNotAllowedException::notBoostable();
|
||
}
|
||
|
||
$credits = $this->pricing->boostCredits($days, $placement);
|
||
|
||
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
|
||
$this->lockSlotInventory($pressRelease, $placement);
|
||
|
||
$availability = $this->slotAvailability($pressRelease, $placement);
|
||
if (! $availability['available']) {
|
||
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
|
||
}
|
||
|
||
$startsAt = $this->activeUntil($pressRelease, $placement) ?? now();
|
||
|
||
$boost = $pressRelease->boosts()->create([
|
||
'user_id' => $user->id,
|
||
'placement' => $placement,
|
||
'days' => $days,
|
||
'credits_charged' => $credits,
|
||
'starts_at' => $startsAt,
|
||
'ends_at' => $startsAt->copy()->addDays($days),
|
||
]);
|
||
|
||
$this->wallet->debit(
|
||
$user,
|
||
$credits,
|
||
"{$placement->label()} {$days} Tage – PM #{$pressRelease->id}",
|
||
$boost,
|
||
);
|
||
|
||
return $boost;
|
||
});
|
||
|
||
$this->forgetPlacementCaches($pressRelease, $placement);
|
||
|
||
return $boost;
|
||
}
|
||
|
||
/**
|
||
* Beendet alle laufenden und bereits angeschlossenen Boosts einer PM
|
||
* sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder
|
||
* wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als
|
||
* Buchungsnachweis erhalten; Credits werden nicht zurückgebucht —
|
||
* Erstattungen laufen getrennt.
|
||
*
|
||
* @return int Anzahl der beendeten Boosts
|
||
*/
|
||
public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int
|
||
{
|
||
$now = now();
|
||
$boosts = $pressRelease->boosts()->active()->get();
|
||
|
||
if ($boosts->isEmpty()) {
|
||
return 0;
|
||
}
|
||
|
||
foreach ($boosts as $boost) {
|
||
$boost->update([
|
||
'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at,
|
||
'ends_at' => $now,
|
||
]);
|
||
}
|
||
|
||
$boosts->pluck('placement')->unique()->each(
|
||
fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement),
|
||
);
|
||
|
||
$this->activityLog->record(
|
||
ActivityType::BoostEnded,
|
||
sprintf(
|
||
'%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s',
|
||
$endedBy->name,
|
||
$boosts->count(),
|
||
$pressRelease->id,
|
||
$reason,
|
||
),
|
||
user: $endedBy,
|
||
subject: $pressRelease,
|
||
meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason],
|
||
);
|
||
|
||
return $boosts->count();
|
||
}
|
||
|
||
/**
|
||
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
||
*
|
||
* Eine eigene stabile Lock-Zeile ist nötig: Ein Lock auf vorhandenen
|
||
* Boost-Zeilen schützt gerade das leere Inventar nicht vor zwei
|
||
* gleichzeitigen Erstkäufen.
|
||
*/
|
||
private function lockSlotInventory(PressRelease $pressRelease, BoostPlacement $placement): void
|
||
{
|
||
$lockKeys = collect($this->affectedPortals($pressRelease))
|
||
->map(fn (string $portal): string => implode(':', [
|
||
$placement->value,
|
||
$portal,
|
||
$pressRelease->language,
|
||
$placement === BoostPlacement::Category ? (string) $pressRelease->category_id : 'all',
|
||
]))
|
||
->sort()
|
||
->values();
|
||
|
||
DB::table('boost_slot_locks')->insertOrIgnore(
|
||
$lockKeys->map(fn (string $lockKey): array => ['lock_key' => $lockKey])->all(),
|
||
);
|
||
|
||
DB::table('boost_slot_locks')
|
||
->whereIn('lock_key', $lockKeys)
|
||
->orderBy('lock_key')
|
||
->lockForUpdate()
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
|
||
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
|
||
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der
|
||
* Kategorie-Boost berührt die Startseite nicht.
|
||
*/
|
||
private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void
|
||
{
|
||
$portals = $this->affectedPortals($pressRelease);
|
||
|
||
// Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite
|
||
// schließt Unterkategorien mit ein), je hinterlegter Sprache.
|
||
$category = $pressRelease->category()
|
||
->with(['translations', 'parent.translations'])
|
||
->first();
|
||
$translations = collect($category?->translations ?? [])
|
||
->merge($category?->parent?->translations ?? []);
|
||
|
||
foreach ($portals as $portal) {
|
||
if ($placement === BoostPlacement::Home) {
|
||
Cache::forget(WebPageCache::key('home', [$portal, $pressRelease->language]));
|
||
}
|
||
|
||
foreach ($translations as $translation) {
|
||
if (filled($translation->slug)) {
|
||
Cache::forget(WebPageCache::key('category', [$portal, $translation->locale, $translation->slug]));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Portale, auf deren Ausgaben die PM erscheint.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function affectedPortals(PressRelease $pressRelease): array
|
||
{
|
||
return $pressRelease->portal === Portal::Both
|
||
? [Portal::Presseecho->value, Portal::Businessportal24->value]
|
||
: [$pressRelease->portal->value];
|
||
}
|
||
|
||
/**
|
||
* Entfernt die (bei Kategorie-Boosts angelegten) Slot-Lock-Zeilen einer
|
||
* gelöschten Rubrik. Die Lock-Keys haben die Form
|
||
* `category:<portal>:<sprache>:<category_id>` — nach dem Löschen der Rubrik
|
||
* sind sie verwaist und werden hier aufgeräumt.
|
||
*/
|
||
public function forgetSlotLocksForCategory(int $categoryId): void
|
||
{
|
||
DB::table('boost_slot_locks')
|
||
->where('lock_key', 'like', 'category:%:'.$categoryId)
|
||
->delete();
|
||
}
|
||
}
|