claimDraft($campaign)) { return 0; } try { $this->freezeRecipients($campaign); } catch (\Throwable $exception) { // Abbruch beim Einfrieren: Entwurf bleibt startbar, bereits // angelegte Zeilen werden beim nächsten Start übersprungen. $this->releaseDraft($campaign); throw $exception; } $total = $campaign->recipients()->count(); if ($total === 0) { $this->releaseDraft($campaign); return 0; } $campaign->forceFill(['recipients_total' => $total])->save(); self::dispatchPending($campaign); return $total; } /** * Plant für jeden ausstehenden Empfänger einen Versand-Job ein, mit * Drossel-Delay in Wellen. Liest aus dem Protokoll, nicht aus dem * Speicher — so nutzt der Wiederanlauf denselben Weg. * * @return int Anzahl der eingeplanten Jobs. */ public static function dispatchPending(NewsletterCampaign $campaign): int { $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); $intervalSeconds = max(1, (int) config('newsletter.campaign.batch_interval_minutes')) * 60; $position = 0; $campaign->recipients() ->toBase() ->select('id') ->where('status', NewsletterRecipientStatus::Pending->value) ->chunkById(500, function ($rows) use ($batchSize, $intervalSeconds, &$position): void { foreach ($rows as $row) { SendNewsletterCampaignMail::dispatch((int) $row->id) ->delay(intdiv($position, $batchSize) * $intervalSeconds); $position++; } }); return $position; } /** * Bedingtes UPDATE draft → sending: Genau ein Aufrufer gewinnt, auch bei * zwei parallel geladenen Model-Instanzen desselben Entwurfs. */ private function claimDraft(NewsletterCampaign $campaign): bool { $claimed = NewsletterCampaign::query() ->whereKey($campaign->id) ->where('status', NewsletterCampaignStatus::Draft->value) ->update([ 'status' => NewsletterCampaignStatus::Sending->value, 'updated_at' => now(), ]); if ($claimed !== 1) { return false; } $campaign->forceFill(['status' => NewsletterCampaignStatus::Sending->value])->syncOriginal(); return true; } private function releaseDraft(NewsletterCampaign $campaign): void { NewsletterCampaign::query() ->whereKey($campaign->id) ->where('status', NewsletterCampaignStatus::Sending->value) ->update(['status' => NewsletterCampaignStatus::Draft->value]); $campaign->forceFill(['status' => NewsletterCampaignStatus::Draft->value])->syncOriginal(); } /** * Schreibt die Empfängerliste laut Filter ins Protokoll. Die Filter- * Sortierung ist eine UI-Sortierung der Vorschau (E4) — vor dem * ID-Chunking wird sie verworfen, sonst liest chunkById ab der zweiten * Seite Empfänger doppelt und lässt andere aus (Audit F05). */ private function freezeRecipients(NewsletterCampaign $campaign): void { $this->recipients->query($campaign->filters ?? []) ->select(['id', 'email']) ->reorder('id') ->chunkById(500, function ($users) use ($campaign): void { $rows = $users->map(fn (User $user): array => [ 'newsletter_campaign_id' => $campaign->id, 'user_id' => $user->id, 'email' => $user->email, 'status' => NewsletterRecipientStatus::Pending->value, 'created_at' => now(), 'updated_at' => now(), ])->all(); $campaign->recipients()->insertOrIgnore($rows); }); } /** * Drossel-Hinweis für die Admin-UI, z. B. „max. 50 Mails alle 15 Minuten — * Gesamtdauer ca. 3,5 Stunden“. Null, wenn alles in die erste Welle passt. */ public static function throttleSummary(int $total): ?string { $batchSize = max(1, (int) config('newsletter.campaign.batch_size')); $intervalMinutes = max(1, (int) config('newsletter.campaign.batch_interval_minutes')); if ($total <= $batchSize) { return null; } $durationMinutes = ((int) ceil($total / $batchSize) - 1) * $intervalMinutes; $duration = $durationMinutes < 60 ? sprintf('%d Minuten', $durationMinutes) : sprintf('%s Stunden', number_format($durationMinutes / 60, 1, ',', '.')); return sprintf('max. %d Mails alle %d Minuten — Gesamtdauer ca. %s', $batchSize, $intervalMinutes, $duration); } }