presseportale/app/Services/Newsletter/CampaignRecipients.php
Kevin Adametz 58c0cc3f23 admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand
- Unterseite /admin/newsletter/abmeldungen listet Newsletter-Opt-outs mit
  Zeitpunkt, Suche und Link zur User-Detailseite
- Kampagnen-Filter: Empfänger einer früheren Kampagne ausschließen
  (Abgleich per User-ID und E-Mail-Snapshot) für Follow-up-Mailings
- Login-Filter kann Monats-Scheiben (von–bis) abgrenzen, um große
  Listen Monat für Monat abzuarbeiten
- Versand läuft in Wellen (Standard 50 Mails / 15 Min, per .env
  justierbar) gegen SMTP-Limits und Blocklisten; Hinweis im Senden-Modal
- web: Firmen-Website im Newsroom & Co. als absolute URL verlinkt
  (Company::websiteUrl), statt relativ auf /newsroom/www.example.de
- kunde: API-Doku-Button öffnet die /de/api-Seite; OpenAPI-Spezifikation
  als eigener, benannter Download (pressekonto-api-v1.yml)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:38:47 +00:00

178 lines
8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Newsletter;
use App\Enums\EmailNotificationType;
use App\Enums\UserPaymentOptionStatus;
use App\Models\NewsletterCampaign;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
/**
* Empfänger-Auswahl für interne Newsletter-Kampagnen (Admin ▸ Newsletter).
*
* Grundmenge sind alle aktiven User mit E-Mail-Adresse, die den Newsletter
* nicht abbestellt haben. Darauf setzen die Kampagnen-Filter auf:
* Portal, Legacy-Vertrag (Bestandsschutz-Zahlungsoption), der
* Login-Zeitraum (eingeloggt innerhalb X Monaten bzw. dessen Komplement:
* länger nicht oder noch nie eingeloggt) und optional der Ausschluss aller
* Empfänger einer früheren Kampagne (Follow-up an die noch nicht
* Angeschriebenen).
*/
class CampaignRecipients
{
public const LOGIN_ANY = 'any';
public const LOGIN_WITHIN = 'within';
public const LOGIN_NOT_WITHIN = 'not_within';
public const LOGIN_BETWEEN = 'between';
public const SORT_FIELDS = ['created_at', 'last_login_at'];
/**
* Filter-Defaults; unbekannte Werte fallen auf diese zurück.
*
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string}
*/
public static function defaultFilters(): array
{
return [
'portal' => '',
'legacy_contract' => false,
'login' => self::LOGIN_ANY,
'login_months' => 6,
'login_months_from' => 0,
'exclude_campaign_id' => 0,
'sort' => 'created_at',
'direction' => 'desc',
];
}
/**
* Bringt einen (z. B. aus der UI oder dem JSON-Snapshot kommenden)
* Filter-Satz in eine validierte, vollständige Form.
*
* @param array<string, mixed> $filters
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string}
*/
public function normalize(array $filters): array
{
$defaults = self::defaultFilters();
$portal = (string) ($filters['portal'] ?? '');
$login = (string) ($filters['login'] ?? $defaults['login']);
$months = max(1, min(120, (int) ($filters['login_months'] ?? $defaults['login_months'])));
$monthsFrom = max(0, (int) ($filters['login_months_from'] ?? $defaults['login_months_from']));
$sort = (string) ($filters['sort'] ?? $defaults['sort']);
$direction = (string) ($filters['direction'] ?? $defaults['direction']);
return [
'portal' => in_array($portal, ['presseecho', 'businessportal24', 'both'], true) ? $portal : '',
'legacy_contract' => (bool) ($filters['legacy_contract'] ?? false),
'login' => in_array($login, [self::LOGIN_ANY, self::LOGIN_WITHIN, self::LOGIN_NOT_WITHIN, self::LOGIN_BETWEEN], true) ? $login : self::LOGIN_ANY,
'login_months' => $months,
// Untergrenze der Monats-Scheibe; liegt immer unter der Obergrenze.
'login_months_from' => min($monthsFrom, $months - 1),
'exclude_campaign_id' => max(0, (int) ($filters['exclude_campaign_id'] ?? 0)),
'sort' => in_array($sort, self::SORT_FIELDS, true) ? $sort : $defaults['sort'],
'direction' => $direction === 'asc' ? 'asc' : 'desc',
];
}
/**
* Menschenlesbare Zusammenfassung eines Filter-Satzes für die Admin-UI.
*
* @param array<string, mixed> $filters
* @return list<string>
*/
public function describe(array $filters): array
{
$filters = $this->normalize($filters);
$parts = ['Aktive User mit E-Mail, Newsletter nicht abbestellt'];
if ($filters['portal'] !== '') {
$parts[] = 'Portal: '.$filters['portal'];
}
if ($filters['legacy_contract']) {
$parts[] = 'Nur User mit Legacy-Vertrag (Bestandsschutz)';
}
if ($filters['login'] === self::LOGIN_WITHIN) {
$parts[] = sprintf('In den letzten %d Monaten eingeloggt', $filters['login_months']);
} elseif ($filters['login'] === self::LOGIN_NOT_WITHIN) {
$parts[] = sprintf('Seit %d Monaten nicht eingeloggt (oder noch nie)', $filters['login_months']);
} elseif ($filters['login'] === self::LOGIN_BETWEEN) {
$parts[] = $filters['login_months_from'] > 0
? sprintf('Letzter Login vor %d bis %d Monaten', $filters['login_months_from'], $filters['login_months'])
: sprintf('In den letzten %d Monaten eingeloggt', $filters['login_months']);
}
if ($filters['exclude_campaign_id'] > 0) {
$excluded = NewsletterCampaign::query()->find($filters['exclude_campaign_id'], ['id', 'subject']);
$parts[] = sprintf(
'Ohne Empfänger der Kampagne „%s“',
$excluded->subject ?? sprintf('#%d (gelöscht)', $filters['exclude_campaign_id']),
);
}
$parts[] = sprintf(
'Sortiert nach %s (%s)',
$filters['sort'] === 'last_login_at' ? 'letztem Login' : 'Erstellungsdatum',
$filters['direction'] === 'asc' ? 'älteste zuerst' : 'neueste zuerst',
);
return $parts;
}
/**
* @param array<string, mixed> $filters
* @return Builder<User>
*/
public function query(array $filters): Builder
{
$filters = $this->normalize($filters);
$loginCutoff = now()->subMonths($filters['login_months']);
return User::query()
->where('is_active', true)
->whereNotNull('email')
->whereDoesntHave('emailNotificationOptOuts', fn (Builder $query) => $query
->where('type', EmailNotificationType::Newsletter->value))
->when($filters['portal'] !== '', fn (Builder $query) => $query
->where('portal', $filters['portal']))
->when($filters['legacy_contract'], fn (Builder $query) => $query
->whereHas('userPaymentOptions', fn (Builder $inner) => $inner
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->where(fn (Builder $until) => $until
->whereNull('grandfathered_until')
->orWhere('grandfathered_until', '>=', now()))))
->when($filters['login'] === self::LOGIN_WITHIN, fn (Builder $query) => $query
->where('last_login_at', '>=', $loginCutoff))
->when($filters['login'] === self::LOGIN_NOT_WITHIN, fn (Builder $query) => $query
->where(fn (Builder $inner) => $inner
->whereNull('last_login_at')
->orWhere('last_login_at', '<', $loginCutoff)))
// Monats-Scheibe [vor Y Monaten, vor X Monaten): Untergrenze
// inklusiv, Obergrenze exklusiv — benachbarte Scheiben (z. B.
// 0–1 und 1–2) überschneiden sich damit nicht.
->when($filters['login'] === self::LOGIN_BETWEEN, fn (Builder $query) => $query
->where('last_login_at', '>=', $loginCutoff)
->where('last_login_at', '<', now()->subMonths($filters['login_months_from'])))
// Follow-up-Ausschluss: wer im Protokoll der gewählten Kampagne
// steht (per User-ID oder E-Mail-Snapshot), fällt raus.
->when($filters['exclude_campaign_id'] > 0, fn (Builder $query) => $query
->whereNotExists(fn (QueryBuilder $sub) => $sub
->from('newsletter_campaign_recipients', 'excluded')
->where('excluded.newsletter_campaign_id', $filters['exclude_campaign_id'])
->where(fn (QueryBuilder $match) => $match
->whereColumn('excluded.user_id', 'users.id')
->orWhereColumn('excluded.email', 'users.email'))))
->orderBy($filters['sort'], $filters['direction'])
->orderBy('id');
}
}