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; } /** * 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(sprintf('web.home.%s.%s', $portal, $pressRelease->language)); } foreach ($translations as $translation) { if (filled($translation->slug)) { Cache::forget(sprintf('web.category.%s.%s.%s', $portal, $translation->locale, $translation->slug)); } } } } /** * Portale, auf deren Ausgaben die PM erscheint. * * @return list */ 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:::` — 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(); } }