Newsletter-Listen: Empfaengerlisten filtern, speichern und exportieren

Admin-Bereich "Newsletter-Listen" (Plan dev/_tickets/entwicklungsplan-
newsletter-listen.md): Berater und Kunden nach Filtern zusammenstellen,
als Liste speichern und wieder laden, Export als XLSX/CSV. Jeder Export
wird protokolliert (newsletter_list_exports).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:44:09 +00:00
parent ef7e333bd4
commit d21d94a060
26 changed files with 3664 additions and 0 deletions

View file

@ -0,0 +1,303 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Exports\xExport;
use App\Http\Controllers\Controller;
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
use App\Http\Requests\Newsletter\StoreNewsletterListRequest;
use App\Models\Country;
use App\Models\NewsletterList;
use App\Models\NewsletterListExport;
use App\Models\UserLevel;
use App\Services\Newsletter\NewsletterListFilter;
use App\Services\Newsletter\NewsletterListResult;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Yajra\DataTables\Facades\DataTables;
class NewsletterListController extends Controller
{
private const FILTER_SETTING_KEY = 'newsletter_list_filter';
public function __construct(private readonly NewsletterListService $service)
{
$this->middleware('admin');
}
public function index(NewsletterListFilterRequest $request): View|RedirectResponse
{
if ($request->get('reset') === 'filter') {
set_user_attr(self::FILTER_SETTING_KEY, null);
return redirect(route('admin_newsletter_lists'));
}
$filter = $request->query() === []
? NewsletterListFilter::fromArray((array) (get_user_attr(self::FILTER_SETTING_KEY) ?: []))
: $request->toFilter();
return $this->renderIndex($filter, $request->listId() ? NewsletterList::find($request->listId()) : null);
}
/**
* Lädt eine gespeicherte Liste in die Filter-Seite.
*/
public function load(NewsletterList $list): View
{
set_user_attr(self::FILTER_SETTING_KEY, $list->toFilter()->toArray());
return $this->renderIndex($list->toFilter(), $list);
}
public function datatable(NewsletterListFilterRequest $request): JsonResponse
{
$filter = $request->toFilter();
set_user_attr(self::FILTER_SETTING_KEY, $filter->toArray());
$result = $this->service->cached($filter, (int) $request->user()->id, $request->boolean('refresh'));
return DataTables::of($result->rows)
->editColumn('email', fn (array $row) => e($row['email']))
->addColumn('name', fn (array $row) => e(trim($row['firstname'].' '.$row['lastname'])))
->editColumn('optin_label', fn (array $row) => $this->optinBadge($row))
->editColumn('status_label', fn (array $row) => $this->statusBadge($row))
->rawColumns(['optin_label', 'status_label'])
->with('stats', $result->stats)
->toJson();
}
public function export(NewsletterListFilterRequest $request, string $format): BinaryFileResponse|StreamedResponse
{
$filter = $request->toFilter();
set_user_attr(self::FILTER_SETTING_KEY, $filter->toArray());
$list = $request->listId() ? NewsletterList::find($request->listId()) : null;
return $this->download($request->user(), $filter, $format, $list);
}
/**
* Export direkt aus der Übersicht der gespeicherten Listen mit den dort hinterlegten Filtern.
*/
public function exportSaved(Request $request, NewsletterList $list, string $format): BinaryFileResponse|StreamedResponse
{
return $this->download($request->user(), $list->toFilter(), $format, $list);
}
public function store(StoreNewsletterListRequest $request): RedirectResponse
{
$list = new NewsletterList([
'name' => $request->listName(),
'created_by' => $request->user()->id,
]);
$list->applyFilter($request->toFilter());
$list->save();
return redirect(route('admin_newsletter_lists_load', [$list->id]))->with('alert-success', __('newsletter.saved.stored'));
}
public function update(StoreNewsletterListRequest $request, NewsletterList $list): RedirectResponse
{
$list->name = $request->listName();
$list->applyFilter($request->toFilter());
$list->save();
return redirect(route('admin_newsletter_lists_load', [$list->id]))->with('alert-success', __('newsletter.saved.updated'));
}
public function destroy(NewsletterList $list): RedirectResponse
{
$list->exports()->update(['newsletter_list_id' => null]);
$list->delete();
return redirect(route('admin_newsletter_lists_saved'))->with('alert-success', __('newsletter.saved.deleted'));
}
public function saved(): View
{
return view('admin.newsletter.saved');
}
public function savedDatatable(): JsonResponse
{
$query = NewsletterList::query()->with('creator.account')->select('newsletter_lists.*');
return DataTables::eloquent($query)
->editColumn('name', fn (NewsletterList $list) => '<a href="'.route('admin_newsletter_lists_load', [$list->id]).'">'.e($list->name).'</a>')
->editColumn('source', fn (NewsletterList $list) => __('newsletter.source_option.'.$list->source))
->addColumn('summary', fn (NewsletterList $list) => e($this->service->describe($list->toFilter())))
->editColumn('last_exported_at', fn (NewsletterList $list) => $list->last_exported_at?->format('d.m.Y H:i') ?? '–')
->editColumn('last_row_count', fn (NewsletterList $list) => $list->last_row_count === null ? '–' : (string) $list->last_row_count)
->addColumn('creator', fn (NewsletterList $list) => e($list->creator?->getFullName(false) ?? ''))
->editColumn('created_at', fn (NewsletterList $list) => $list->created_at?->format('d.m.Y') ?? '')
->addColumn('actions', fn (NewsletterList $list) => $this->savedActions($list))
->orderColumn('name', 'name $1')
->orderColumn('last_exported_at', 'last_exported_at $1')
->orderColumn('last_row_count', 'last_row_count $1')
->orderColumn('created_at', 'created_at $1')
->rawColumns(['name', 'actions'])
->make(true);
}
public function exports(): View
{
return view('admin.newsletter.exports');
}
public function exportsDatatable(): JsonResponse
{
$query = NewsletterListExport::query()->with(['list', 'user.account'])->select('newsletter_list_exports.*');
return DataTables::eloquent($query)
->editColumn('created_at', fn (NewsletterListExport $export) => $export->created_at?->format('d.m.Y H:i') ?? '')
->addColumn('user', fn (NewsletterListExport $export) => e($export->user?->getFullName(false) ?? ''))
->addColumn('list', function (NewsletterListExport $export) {
if ($export->list) {
return '<a href="'.route('admin_newsletter_lists_load', [$export->list->id]).'">'.e($export->list->name).'</a>';
}
return '<span class="text-muted">'.e(__('newsletter.exports.adhoc')).'</span>';
})
->addColumn('summary', fn (NewsletterListExport $export) => e($this->service->describe(NewsletterListFilter::fromArray((array) $export->filters))))
->editColumn('format', fn (NewsletterListExport $export) => strtoupper($export->format))
->orderColumn('created_at', 'created_at $1')
->orderColumn('row_count', 'row_count $1')
->orderColumn('format', 'format $1')
->rawColumns(['list'])
->make(true);
}
private function renderIndex(NewsletterListFilter $filter, ?NewsletterList $list): View
{
return view('admin.newsletter.index', [
'filter' => $filter->toArray(),
'list' => $list,
'countries' => Country::query()->orderBy(app()->getLocale() === 'en' ? 'en' : 'de')->get(),
'members' => User::query()
->with('account')
->whereNotNull('account_id')
->where('admin', '<', 5)
->get()
->sortBy(fn (User $user) => mb_strtolower($user->getFullName(false)))
->values(),
'levels' => UserLevel::query()->orderBy('pos')->get(),
'languages' => ['de', 'en', 'es', 'fr'],
'sources' => NewsletterListFilter::sources(),
'optin_options' => NewsletterListFilter::optinOptions(),
'purchase_options' => NewsletterListFilter::purchaseOptions(),
'company_options' => NewsletterListFilter::companyOptions(),
'consultant_status_options' => NewsletterListFilter::consultantStatusOptions(),
]);
}
/**
* Baut den Download, protokolliert den Export und aktualisiert die gespeicherte Liste.
*/
private function download(User $user, NewsletterListFilter $filter, string $format, ?NewsletterList $list): BinaryFileResponse|StreamedResponse
{
$result = $this->service->build($filter);
$headers = $this->service->exportHeaders($filter->templateOnly);
$rows = $this->service->exportRows($result->rows, $filter->templateOnly);
$filename = 'newsletter_'.$filter->source.'_'.($filter->language ?? 'alle').'_'.now()->format('Y-m-d').'.'.$format;
$this->logExport($user, $filter, $format, $filename, $result, $list);
if ($format === 'csv') {
return $this->streamCsv($filename, $headers, $rows);
}
return Excel::download(new xExport($rows, $headers), $filename);
}
private function logExport(User $user, NewsletterListFilter $filter, string $format, string $filename, NewsletterListResult $result, ?NewsletterList $list): void
{
NewsletterListExport::create([
'newsletter_list_id' => $list?->id,
'user_id' => $user->id,
'format' => $format,
'row_count' => $result->stats['total'],
'filename' => $filename,
'filters' => $filter->toArray(),
'stats' => $result->stats,
]);
if ($list !== null) {
$list->forceFill([
'last_exported_at' => now(),
'last_row_count' => $result->stats['total'],
])->save();
}
}
/**
* @param string[] $headers
* @param array<int, array<int, string>> $rows
*/
private function streamCsv(string $filename, array $headers, array $rows): StreamedResponse
{
return response()->streamDownload(function () use ($headers, $rows) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $headers, ';', '"', '\\');
foreach ($rows as $row) {
fputcsv($out, self::csvSafeRow($row), ';', '"', '\\');
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* Schützt vor CSV-Injection: Werte, die mit Formelzeichen beginnen, werden entschärft.
*
* @param array<int, string> $row
* @return array<int, string>
*/
private static function csvSafeRow(array $row): array
{
return array_map(static function (string $value) {
return $value !== '' && str_contains("=+-@\t\r", $value[0]) ? "'".$value : $value;
}, $row);
}
private function savedActions(NewsletterList $list): string
{
return '<a href="'.route('admin_newsletter_lists_load', [$list->id]).'" class="btn btn-sm btn-primary" title="'.e(__('newsletter.saved.load')).'"><i class="fa fa-filter"></i></a> '
.'<a href="'.route('admin_newsletter_lists_saved_export', [$list->id, 'xlsx']).'" class="btn btn-sm btn-success" title="XLSX"><i class="fa fa-file-excel"></i></a> '
.'<a href="'.route('admin_newsletter_lists_saved_export', [$list->id, 'csv']).'" class="btn btn-sm btn-outline-success" title="CSV"><i class="fa fa-file-csv"></i></a> '
.'<form method="post" action="'.route('admin_newsletter_lists_destroy', [$list->id]).'" class="d-inline" onsubmit="return confirm(\''.e(__('newsletter.saved.confirm_delete')).'\');">'
.'<input type="hidden" name="_token" value="'.csrf_token().'">'
.'<button type="submit" class="btn btn-sm btn-outline-danger" title="'.e(__('newsletter.saved.delete')).'"><i class="fa fa-trash-alt"></i></button></form>';
}
/**
* @param array<string, mixed> $row
*/
private function optinBadge(array $row): string
{
$class = match ($row['optin']) {
NewsletterListService::OPTIN_YES => 'badge-success',
NewsletterListService::OPTIN_CONTRACT => 'badge-info',
default => 'badge-danger',
};
return '<span class="badge badge-pill '.$class.'">'.e($row['optin_label']).'</span>';
}
/**
* @param array<string, mixed> $row
*/
private function statusBadge(array $row): string
{
$class = $row['status'] === NewsletterListService::STATUS_ACTIVE ? 'badge-success' : 'badge-secondary';
return '<span class="badge badge-pill '.$class.'">'.e($row['status_label']).'</span>';
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Requests\Newsletter;
use App\Services\Newsletter\NewsletterListFilter;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class NewsletterListFilterRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->isAdmin();
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'source' => ['nullable', Rule::in(NewsletterListFilter::sources())],
'language' => ['nullable', 'string', 'max:2'],
'country_ids' => ['nullable', 'array'],
'country_ids.*' => ['integer'],
'optin' => ['nullable', Rule::in(NewsletterListFilter::optinOptions())],
'purchase' => ['nullable', Rule::in(NewsletterListFilter::purchaseOptions())],
'member_id' => ['nullable', 'regex:/^(none|\d+)$/'],
'created_from' => ['nullable', 'date'],
'created_to' => ['nullable', 'date', 'after_or_equal:created_from'],
'company' => ['nullable', Rule::in(NewsletterListFilter::companyOptions())],
'consultant_status' => ['nullable', Rule::in(NewsletterListFilter::consultantStatusOptions())],
'level_ids' => ['nullable', 'array'],
'level_ids.*' => ['integer'],
'include_admins' => ['nullable', 'boolean'],
'template_only' => ['nullable', 'boolean'],
'refresh' => ['nullable', 'boolean'],
'list_id' => ['nullable', 'integer', 'exists:newsletter_lists,id'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'source.in' => __('newsletter.validation.source'),
'optin.in' => __('newsletter.validation.option'),
'purchase.in' => __('newsletter.validation.option'),
'company.in' => __('newsletter.validation.option'),
'consultant_status.in' => __('newsletter.validation.option'),
'member_id.regex' => __('newsletter.validation.member'),
'created_from.date' => __('newsletter.validation.date'),
'created_to.date' => __('newsletter.validation.date'),
'created_to.after_or_equal' => __('newsletter.validation.date_range'),
'country_ids.*.integer' => __('newsletter.validation.option'),
'level_ids.*.integer' => __('newsletter.validation.option'),
'list_id.exists' => __('newsletter.validation.list'),
'list_id.integer' => __('newsletter.validation.list'),
];
}
public function toFilter(): NewsletterListFilter
{
return NewsletterListFilter::fromArray($this->validated());
}
public function listId(): ?int
{
$id = $this->validated('list_id');
return $id === null || $id === '' ? null : (int) $id;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Newsletter;
/**
* Speichern einer Filterkombination unter einem Namen. Erbt die Filter-Regeln.
*/
class StoreNewsletterListRequest extends NewsletterListFilterRequest
{
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return parent::rules() + [
'name' => ['required', 'string', 'max:120'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return parent::messages() + [
'name.required' => __('newsletter.validation.name'),
'name.max' => __('newsletter.validation.name'),
];
}
public function listName(): string
{
return trim((string) $this->validated('name'));
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Services\Newsletter\NewsletterListFilter;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Gespeicherte Filterkombination für den Newsletter-Export.
*/
class NewsletterList extends Model
{
protected $fillable = [
'name',
'source',
'filters',
'created_by',
'last_exported_at',
'last_row_count',
];
protected function casts(): array
{
return [
'filters' => 'array',
'last_exported_at' => 'datetime',
'last_row_count' => 'integer',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function exports(): HasMany
{
return $this->hasMany(NewsletterListExport::class, 'newsletter_list_id');
}
public function toFilter(): NewsletterListFilter
{
return NewsletterListFilter::fromArray((array) $this->filters);
}
public function applyFilter(NewsletterListFilter $filter): void
{
$this->source = $filter->source;
$this->filters = $filter->toArray();
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Protokolleintrag eines Newsletter-Exports (wer, wann, welches Format, wie viele Adressen).
*/
class NewsletterListExport extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'newsletter_list_id',
'user_id',
'format',
'row_count',
'filename',
'filters',
'stats',
];
protected function casts(): array
{
return [
'filters' => 'array',
'stats' => 'array',
'row_count' => 'integer',
'created_at' => 'datetime',
];
}
public function list(): BelongsTo
{
return $this->belongsTo(NewsletterList::class, 'newsletter_list_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View file

@ -0,0 +1,247 @@
<?php
namespace App\Services\Newsletter;
use Carbon\Carbon;
/**
* Wertobjekt für die Filter der Newsletter-Listen. Wird aus dem validierten
* Request gebaut und ist die einzige Quelle für Defaults und erlaubte Werte.
*/
final class NewsletterListFilter
{
public const SOURCE_CUSTOMERS = 'customers';
public const SOURCE_CONSULTANTS = 'consultants';
public const SOURCE_BOTH = 'both';
public const OPTIN_ALL = 'all';
public const OPTIN_SUBSCRIBED = 'subscribed';
public const OPTIN_UNSUBSCRIBED = 'unsubscribed';
public const PURCHASE_ALL = 'all';
public const PURCHASE_HAS_BUYED = 'has_buyed';
public const PURCHASE_NONE = 'none';
public const PURCHASE_MONTHS_3 = 'months_3';
public const PURCHASE_MONTHS_6 = 'months_6';
public const PURCHASE_MONTHS_12 = 'months_12';
public const PURCHASE_ACTIVE_ABO = 'active_abo';
public const COMPANY_ALL = 'all';
public const COMPANY_WITH = 'with';
public const COMPANY_WITHOUT = 'without';
public const CONSULTANT_STATUS_ACTIVE = 'active';
public const CONSULTANT_STATUS_INACTIVE = 'inactive';
public const CONSULTANT_STATUS_ALL = 'all';
public const MEMBER_NONE = 'none';
/**
* @param int[] $countryIds
* @param int[] $levelIds
*/
public function __construct(
public readonly string $source = self::SOURCE_CUSTOMERS,
public readonly ?string $language = 'de',
public readonly array $countryIds = [],
public readonly string $optin = self::OPTIN_ALL,
public readonly string $purchase = self::PURCHASE_ALL,
public readonly ?string $memberId = null,
public readonly ?Carbon $createdFrom = null,
public readonly ?Carbon $createdTo = null,
public readonly string $company = self::COMPANY_ALL,
public readonly string $consultantStatus = self::CONSULTANT_STATUS_ACTIVE,
public readonly array $levelIds = [],
public readonly bool $includeAdmins = false,
public readonly bool $templateOnly = false,
) {}
/**
* @return string[]
*/
public static function sources(): array
{
return [self::SOURCE_CUSTOMERS, self::SOURCE_CONSULTANTS, self::SOURCE_BOTH];
}
/**
* @return string[]
*/
public static function optinOptions(): array
{
return [self::OPTIN_ALL, self::OPTIN_SUBSCRIBED, self::OPTIN_UNSUBSCRIBED];
}
/**
* @return string[]
*/
public static function purchaseOptions(): array
{
return [
self::PURCHASE_ALL,
self::PURCHASE_HAS_BUYED,
self::PURCHASE_NONE,
self::PURCHASE_MONTHS_3,
self::PURCHASE_MONTHS_6,
self::PURCHASE_MONTHS_12,
self::PURCHASE_ACTIVE_ABO,
];
}
/**
* @return string[]
*/
public static function companyOptions(): array
{
return [self::COMPANY_ALL, self::COMPANY_WITH, self::COMPANY_WITHOUT];
}
/**
* @return string[]
*/
public static function consultantStatusOptions(): array
{
return [self::CONSULTANT_STATUS_ACTIVE, self::CONSULTANT_STATUS_INACTIVE, self::CONSULTANT_STATUS_ALL];
}
/**
* Baut den Filter aus validierten Request-Daten. Fehlende Werte fallen auf die Defaults zurück;
* ein leerer String bedeutet bewusst "kein Filter" (z. B. Sprache "alle").
*
* @param array<string, mixed> $input
*/
public static function fromArray(array $input): self
{
$language = array_key_exists('language', $input) ? (string) $input['language'] : 'de';
$memberId = isset($input['member_id']) && $input['member_id'] !== '' ? (string) $input['member_id'] : null;
return new self(
source: self::pick($input, 'source', self::sources(), self::SOURCE_CUSTOMERS),
language: $language === '' ? null : $language,
countryIds: self::intList($input['country_ids'] ?? []),
optin: self::pick($input, 'optin', self::optinOptions(), self::OPTIN_ALL),
purchase: self::pick($input, 'purchase', self::purchaseOptions(), self::PURCHASE_ALL),
memberId: $memberId,
createdFrom: self::date($input['created_from'] ?? null)?->startOfDay(),
createdTo: self::date($input['created_to'] ?? null)?->endOfDay(),
company: self::pick($input, 'company', self::companyOptions(), self::COMPANY_ALL),
consultantStatus: self::pick($input, 'consultant_status', self::consultantStatusOptions(), self::CONSULTANT_STATUS_ACTIVE),
levelIds: self::intList($input['level_ids'] ?? []),
includeAdmins: filter_var($input['include_admins'] ?? false, FILTER_VALIDATE_BOOLEAN),
templateOnly: filter_var($input['template_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'source' => $this->source,
'language' => $this->language ?? '',
'country_ids' => $this->countryIds,
'optin' => $this->optin,
'purchase' => $this->purchase,
'member_id' => $this->memberId ?? '',
'created_from' => $this->createdFrom?->format('Y-m-d') ?? '',
'created_to' => $this->createdTo?->format('Y-m-d') ?? '',
'company' => $this->company,
'consultant_status' => $this->consultantStatus,
'level_ids' => $this->levelIds,
'include_admins' => $this->includeAdmins,
'template_only' => $this->templateOnly,
];
}
public function includesCustomers(): bool
{
return $this->source !== self::SOURCE_CONSULTANTS;
}
public function includesConsultants(): bool
{
return $this->source !== self::SOURCE_CUSTOMERS;
}
public function purchaseMonths(): ?int
{
return match ($this->purchase) {
self::PURCHASE_MONTHS_3 => 3,
self::PURCHASE_MONTHS_6 => 6,
self::PURCHASE_MONTHS_12 => 12,
default => null,
};
}
public function memberIdIsNone(): bool
{
return $this->memberId === self::MEMBER_NONE;
}
public function memberIdValue(): ?int
{
return $this->memberId !== null && ctype_digit($this->memberId) ? (int) $this->memberId : null;
}
/**
* Stabiler Schlüssel für das Zwischenspeichern der Ergebnisliste (Export-Option zählt nicht mit).
*/
public function cacheKey(): string
{
$data = $this->toArray();
unset($data['template_only']);
return md5(json_encode($data));
}
/**
* @param array<string, mixed> $input
* @param string[] $allowed
*/
private static function pick(array $input, string $key, array $allowed, string $default): string
{
$value = (string) ($input[$key] ?? '');
return in_array($value, $allowed, true) ? $value : $default;
}
/**
* @return int[]
*/
private static function intList(mixed $value): array
{
if (! is_array($value)) {
$value = $value === null || $value === '' ? [] : [$value];
}
return array_values(array_unique(array_map('intval', array_filter($value, fn ($v) => $v !== '' && $v !== null))));
}
private static function date(mixed $value): ?Carbon
{
if ($value === null || $value === '') {
return null;
}
try {
return Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\Newsletter;
use Illuminate\Support\Collection;
/**
* Ergebnis einer Listen-Berechnung: deduplizierte Zeilen plus Kennzahlen,
* die erklären, warum Datensätze weggefallen sind.
*/
final class NewsletterListResult
{
/**
* @param Collection<int, array<string, mixed>> $rows
* @param array{customers_raw:int, consultants_raw:int, invalid:int, duplicates:int, total:int, customers:int, consultants:int} $stats
*/
public function __construct(
public readonly Collection $rows,
public readonly array $stats,
) {}
}

View file

@ -0,0 +1,573 @@
<?php
namespace App\Services\Newsletter;
use App\Models\Country;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserLevel;
use App\Services\HTMLHelper;
use App\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Stellt Newsletter-Empfängerlisten aus Kunden (shopping_users) und Beratern
* (users + user_accounts) zusammen. Pflichtausschlüsse (gelöscht, anonymisiert,
* Fake-Mails, Test-Datensätze) sind fest verdrahtet, alles andere kommt aus dem Filter.
* Doppelte E-Mails werden über beide Quellen hinweg entfernt.
*/
class NewsletterListService
{
public const SOURCE_CUSTOMER = 'customer';
public const SOURCE_CONSULTANT = 'consultant';
public const OPTIN_YES = 'yes';
public const OPTIN_NO = 'no';
public const OPTIN_CONTRACT = 'contract';
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
private const CACHE_TTL_SECONDS = 300;
private const CHUNK_SIZE = 1000;
/**
* @var array<string, array<string, string>>|null
*/
private ?array $labels = null;
private ?string $fallbackLocale = null;
/**
* Spaltenschlüssel in Export-Reihenfolge. Die ersten neun entsprechen der Vorlage
* "Kundenliste" (rapidmail), der Rest sind Zusatzspalten.
*
* @var string[]
*/
private const TEMPLATE_COLUMNS = [
'email', 'firstname', 'lastname', 'salutation_label', 'street', 'zipcode', 'city', 'company', 'consultant',
];
/**
* @var string[]
*/
private const EXTRA_COLUMNS = [
'country', 'language', 'source_label', 'number', 'consultant_number', 'optin_label', 'status_label', 'created_at',
];
public function build(NewsletterListFilter $filter): NewsletterListResult
{
$stats = [
'customers_raw' => 0,
'consultants_raw' => 0,
'invalid' => 0,
'duplicates' => 0,
'total' => 0,
'customers' => 0,
'consultants' => 0,
];
$valid = 0;
/** @var array<string, array<string, mixed>> $best */
$best = [];
$collect = function (array $row) use (&$best, &$valid, &$stats): void {
if (! self::isValidEmail($row['email'])) {
$stats['invalid']++;
return;
}
$valid++;
$email = $row['email'];
if (! isset($best[$email]) || self::outranks($row, $best[$email])) {
$best[$email] = $row;
}
};
if ($filter->includesConsultants()) {
foreach ($this->consultantQuery($filter)->toBase()->lazyById(self::CHUNK_SIZE, 'users.id', 'id') as $user) {
$stats['consultants_raw']++;
$collect($this->mapConsultant($user));
}
}
if ($filter->includesCustomers()) {
foreach ($this->customerQuery($filter)->toBase()->lazyById(self::CHUNK_SIZE, 'shopping_users.id', 'id') as $shoppingUser) {
$stats['customers_raw']++;
$collect($this->mapCustomer($shoppingUser));
}
}
ksort($best, SORT_NATURAL | SORT_FLAG_CASE);
$stats['duplicates'] = $valid - count($best);
$stats['total'] = count($best);
foreach ($best as $row) {
$row['source'] === self::SOURCE_CUSTOMER ? $stats['customers']++ : $stats['consultants']++;
}
return new NewsletterListResult(collect(array_values($best)), $stats);
}
/**
* Dedupe-Priorität: Berater vor Kunde, Opt-in vor ohne, Käufer vor Nicht-Käufer, dann neuester Datensatz.
*
* @param array<string, mixed> $candidate
* @param array<string, mixed> $current
*/
private static function outranks(array $candidate, array $current): bool
{
if ($candidate['priority'] !== $current['priority']) {
return $candidate['priority'] > $current['priority'];
}
return $candidate['id'] > $current['id'];
}
/**
* Zwischengespeicherte Variante für die Vorschau-Datatable, damit Blättern und
* Sortieren nicht jedes Mal die komplette Liste neu berechnen.
*/
public function cached(NewsletterListFilter $filter, int $userId, bool $refresh = false): NewsletterListResult
{
$key = 'newsletter_list:'.$userId.':'.$filter->cacheKey();
if ($refresh) {
Cache::forget($key);
}
return Cache::remember($key, self::CACHE_TTL_SECONDS, fn () => $this->build($filter));
}
/**
* @return Builder<ShoppingUser>
*/
public function customerQuery(NewsletterListFilter $filter): Builder
{
$countryName = 'bc.'.$this->countryNameColumn();
$query = ShoppingUser::query()
->select([
'shopping_users.id', 'shopping_users.billing_email', 'shopping_users.billing_firstname', 'shopping_users.billing_lastname',
'shopping_users.billing_salutation', 'shopping_users.billing_address', 'shopping_users.billing_address_2',
'shopping_users.billing_zipcode', 'shopping_users.billing_city', 'shopping_users.billing_company',
'shopping_users.language', 'shopping_users.number', 'shopping_users.subscribed', 'shopping_users.has_buyed',
'shopping_users.created_at',
'bc.code as country_code', $countryName.' as country_name',
'ma.first_name as consultant_firstname', 'ma.last_name as consultant_lastname', 'ma.m_account as consultant_number',
])
->leftJoin('countries as bc', 'bc.id', '=', 'shopping_users.billing_country_id')
->leftJoin('users as m', 'm.id', '=', 'shopping_users.member_id')
->leftJoin('user_accounts as ma', 'ma.id', '=', 'm.account_id')
->whereNull('shopping_users.user_deleted_at')
->where(fn ($q) => $q->whereNull('shopping_users.auth_user_id')->orWhere('shopping_users.auth_user_id', 0))
->where(fn ($q) => $q->whereNull('shopping_users.faker_mail')->orWhere('shopping_users.faker_mail', 0))
->whereNotNull('shopping_users.billing_email')
->where('shopping_users.billing_email', '!=', '')
->where('shopping_users.billing_email', 'not like', '%@dsgvo.invalid')
->where('shopping_users.billing_email', 'not like', '%faker@mivita.care')
->where(fn ($q) => $q->whereNull('shopping_users.mode')->orWhere('shopping_users.mode', 'live'));
$this->applyLanguage($query, 'shopping_users.language', $filter->language);
if ($filter->countryIds !== []) {
$query->whereIn('shopping_users.billing_country_id', $filter->countryIds);
}
if ($filter->optin === NewsletterListFilter::OPTIN_SUBSCRIBED) {
$query->where('shopping_users.subscribed', 1);
} elseif ($filter->optin === NewsletterListFilter::OPTIN_UNSUBSCRIBED) {
$query->where(fn ($q) => $q->whereNull('shopping_users.subscribed')->orWhere('shopping_users.subscribed', 0));
}
$this->applyPurchase($query, $filter);
if ($filter->memberIdIsNone()) {
$query->where(fn ($q) => $q->whereNull('shopping_users.member_id')->orWhere('shopping_users.member_id', 0));
} elseif ($filter->memberIdValue() !== null) {
$query->where('shopping_users.member_id', $filter->memberIdValue());
}
$this->applyCreatedRange($query, 'shopping_users.created_at', $filter);
$this->applyCompany($query, 'shopping_users.billing_company', $filter->company);
return $query;
}
/**
* @return Builder<User>
*/
public function consultantQuery(NewsletterListFilter $filter): Builder
{
$countryName = 'c.'.$this->countryNameColumn();
$query = User::query()
->select([
'users.id', 'users.email', 'users.lang', 'users.active', 'users.created_at',
'a.salutation', 'a.first_name', 'a.last_name', 'a.address', 'a.address_2', 'a.zipcode', 'a.city', 'a.company',
'a.m_account as number',
'c.code as country_code', $countryName.' as country_name',
'sa.first_name as consultant_firstname', 'sa.last_name as consultant_lastname', 'sa.m_account as consultant_number',
'l.name as level',
])
->leftJoin('user_accounts as a', 'a.id', '=', 'users.account_id')
->leftJoin('countries as c', 'c.id', '=', 'a.country_id')
->leftJoin('users as s', 's.id', '=', 'users.m_sponsor')
->leftJoin('user_accounts as sa', 'sa.id', '=', 's.account_id')
->leftJoin('user_levels as l', 'l.id', '=', 'users.m_level')
->whereNull('users.pre_deleted_at')
->where(fn ($q) => $q->whereNull('users.blocked')->orWhere('users.blocked', 0))
->where('users.admin', '<', $filter->includeAdmins ? 5 : 2)
->where(fn ($q) => $q->whereNull('users.test_mode')->orWhere('users.test_mode', 0))
->whereNotNull('users.email')
->where('users.email', '!=', '')
->where('users.email', 'not like', 'delete-%');
$this->applyLanguage($query, 'users.lang', $filter->language);
if ($filter->countryIds !== []) {
$query->whereIn('a.country_id', $filter->countryIds);
}
if ($filter->consultantStatus === NewsletterListFilter::CONSULTANT_STATUS_ACTIVE) {
$query->where('users.active', 1);
} elseif ($filter->consultantStatus === NewsletterListFilter::CONSULTANT_STATUS_INACTIVE) {
$query->where(fn ($q) => $q->whereNull('users.active')->orWhere('users.active', 0));
}
if ($filter->levelIds !== []) {
$query->whereIn('users.m_level', $filter->levelIds);
}
if ($filter->memberIdIsNone()) {
$query->where(fn ($q) => $q->whereNull('users.m_sponsor')->orWhere('users.m_sponsor', 0));
} elseif ($filter->memberIdValue() !== null) {
$query->where('users.m_sponsor', $filter->memberIdValue());
}
$this->applyCreatedRange($query, 'users.created_at', $filter);
$this->applyCompany($query, 'a.company', $filter->company);
return $query;
}
/**
* Kopfzeile für XLSX/CSV.
*
* @return string[]
*/
public function exportHeaders(bool $templateOnly): array
{
return array_map(
fn (string $column) => __('newsletter.export.'.$column),
$this->exportColumns($templateOnly)
);
}
/**
* Zeilen für XLSX/CSV in Kopfzeilen-Reihenfolge.
*
* @param Collection<int, array<string, mixed>> $rows
* @return array<int, array<int, string>>
*/
public function exportRows(Collection $rows, bool $templateOnly): array
{
$columns = $this->exportColumns($templateOnly);
return $rows
->map(fn (array $row) => array_map(fn (string $column) => (string) ($row[$column] ?? ''), $columns))
->values()
->all();
}
/**
* @return string[]
*/
public function exportColumns(bool $templateOnly): array
{
return $templateOnly ? self::TEMPLATE_COLUMNS : array_merge(self::TEMPLATE_COLUMNS, self::EXTRA_COLUMNS);
}
/**
* Lesbare Kurzfassung eines Filters für Listen-Übersicht und Protokoll,
* z. B. "Kunden · Sprache: DE · Land: AT, CH · Opt-in: nur mit Opt-in".
*/
public function describe(NewsletterListFilter $filter): string
{
$parts = [__('newsletter.source_option.'.$filter->source)];
$parts[] = __('newsletter.filter.language').': '.($filter->language === null ? __('newsletter.filter.all') : strtoupper($filter->language));
if ($filter->countryIds !== []) {
$codes = Country::query()->whereIn('id', $filter->countryIds)->orderBy('code')->pluck('code')->all();
$parts[] = __('newsletter.filter.country').': '.implode(', ', $codes);
}
if ($filter->includesCustomers()) {
if ($filter->optin !== NewsletterListFilter::OPTIN_ALL) {
$parts[] = __('newsletter.filter.optin').': '.__('newsletter.optin_option.'.$filter->optin);
}
if ($filter->purchase !== NewsletterListFilter::PURCHASE_ALL) {
$parts[] = __('newsletter.filter.purchase').': '.__('newsletter.purchase_option.'.$filter->purchase);
}
}
if ($filter->memberIdIsNone()) {
$parts[] = __('newsletter.filter.member').': '.__('newsletter.filter.none_member');
} elseif ($filter->memberIdValue() !== null) {
$account = UserAccount::query()
->join('users', 'users.account_id', '=', 'user_accounts.id')
->where('users.id', $filter->memberIdValue())
->first(['user_accounts.first_name', 'user_accounts.last_name']);
$parts[] = __('newsletter.filter.member').': '.($account ? self::fullName($account->first_name, $account->last_name) : '#'.$filter->memberIdValue());
}
if ($filter->createdFrom !== null || $filter->createdTo !== null) {
$parts[] = __('newsletter.filter.created_from').': '.($filter->createdFrom?->format('d.m.Y') ?? '…').' – '.($filter->createdTo?->format('d.m.Y') ?? '…');
}
if ($filter->company !== NewsletterListFilter::COMPANY_ALL) {
$parts[] = __('newsletter.filter.company').': '.__('newsletter.company_option.'.$filter->company);
}
if ($filter->includesConsultants()) {
$parts[] = __('newsletter.filter.consultant_status').': '.__('newsletter.consultant_status_option.'.$filter->consultantStatus);
if ($filter->levelIds !== []) {
$names = UserLevel::query()->whereIn('id', $filter->levelIds)->orderBy('pos')->pluck('name')->all();
$parts[] = __('newsletter.filter.level').': '.implode(', ', $names);
}
if ($filter->includeAdmins) {
$parts[] = __('newsletter.filter.include_admins');
}
}
return implode(' · ', $parts);
}
public static function normalizeEmail(?string $email): string
{
return mb_strtolower(trim((string) $email));
}
public static function isValidEmail(string $email): bool
{
return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* @return array<string, mixed>
*/
private function mapCustomer(object $row): array
{
$subscribed = (bool) $row->subscribed;
$hasBuyed = (bool) $row->has_buyed;
return $this->row([
'id' => (int) $row->id,
'source' => self::SOURCE_CUSTOMER,
'email' => self::normalizeEmail($row->billing_email),
'firstname' => trim((string) $row->billing_firstname),
'lastname' => trim((string) $row->billing_lastname),
'salutation' => (string) $row->billing_salutation,
'street' => self::joinAddress($row->billing_address, $row->billing_address_2),
'zipcode' => trim((string) $row->billing_zipcode),
'city' => trim((string) $row->billing_city),
'company' => trim((string) $row->billing_company),
'consultant' => self::fullName($row->consultant_firstname, $row->consultant_lastname),
'consultant_number' => (string) ($row->consultant_number ?? ''),
'country' => (string) ($row->country_code ?? ''),
'country_name' => (string) ($row->country_name ?? ''),
'language' => $this->languageOrFallback($row->language),
'number' => (string) ($row->number ?? ''),
'optin' => $subscribed ? self::OPTIN_YES : self::OPTIN_NO,
'status' => self::STATUS_ACTIVE,
'created_at' => self::dateOnly($row->created_at),
'priority' => 100 + ($subscribed ? 10 : 0) + ($hasBuyed ? 1 : 0),
]);
}
/**
* @return array<string, mixed>
*/
private function mapConsultant(object $row): array
{
$active = (bool) $row->active;
return $this->row([
'id' => (int) $row->id,
'source' => self::SOURCE_CONSULTANT,
'email' => self::normalizeEmail($row->email),
'firstname' => trim((string) $row->first_name),
'lastname' => trim((string) $row->last_name),
'salutation' => (string) ($row->salutation ?? ''),
'street' => self::joinAddress($row->address, $row->address_2),
'zipcode' => trim((string) $row->zipcode),
'city' => trim((string) $row->city),
'company' => trim((string) $row->company),
'consultant' => self::fullName($row->consultant_firstname, $row->consultant_lastname),
'consultant_number' => (string) ($row->consultant_number ?? ''),
'country' => (string) ($row->country_code ?? ''),
'country_name' => (string) ($row->country_name ?? ''),
'language' => $this->languageOrFallback($row->lang),
'number' => (string) ($row->number ?? ''),
'optin' => self::OPTIN_CONTRACT,
'status' => $active ? self::STATUS_ACTIVE : self::STATUS_INACTIVE,
'level' => (string) ($row->level ?? ''),
'created_at' => self::dateOnly($row->created_at),
'priority' => 1000 + ($active ? 1 : 0),
]);
}
/**
* Ergänzt die sprachabhängigen Anzeige-Spalten, damit Vorschau und Export dieselben Werte zeigen.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function row(array $row): array
{
$labels = $this->labels();
$row['salutation_label'] = $labels['salutation'][$row['salutation']] ?? '';
$row['source_label'] = $labels['source'][$row['source']] ?? '';
$row['optin_label'] = $labels['optin'][$row['optin']] ?? '';
$row['status_label'] = $labels['status'][$row['status']] ?? '';
$row['level'] ??= '';
return $row;
}
/**
* Übersetzungen einmal pro Lauf auflösen statt pro Zeile (bei 70k Zeilen spürbar).
*
* @return array<string, array<string, string>>
*/
private function labels(): array
{
return $this->labels ??= [
'salutation' => [
'mr' => HTMLHelper::getSalutationLang('mr'),
'ms' => HTMLHelper::getSalutationLang('ms'),
'di' => HTMLHelper::getSalutationLang('di'),
],
'source' => [
self::SOURCE_CUSTOMER => __('newsletter.source.customer'),
self::SOURCE_CONSULTANT => __('newsletter.source.consultant'),
],
'optin' => [
self::OPTIN_YES => __('newsletter.optin_value.yes'),
self::OPTIN_NO => __('newsletter.optin_value.no'),
self::OPTIN_CONTRACT => __('newsletter.optin_value.contract'),
],
'status' => [
self::STATUS_ACTIVE => __('newsletter.status_value.active'),
self::STATUS_INACTIVE => __('newsletter.status_value.inactive'),
],
];
}
private static function joinAddress(?string $address, ?string $address2): string
{
return trim(implode(', ', array_filter([trim((string) $address), trim((string) $address2)])));
}
private static function fullName(?string $firstname, ?string $lastname): string
{
return trim(trim((string) $firstname).' '.trim((string) $lastname));
}
private function languageOrFallback(?string $language): string
{
$language = trim((string) $language);
return $language !== '' ? $language : ($this->fallbackLocale ??= (string) config('app.fallback_locale', 'de'));
}
private static function dateOnly(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
return substr((string) $value, 0, 10);
}
/**
* Spalte der countries-Tabelle mit dem Ländernamen in der aktuellen Oberflächensprache.
*/
private function countryNameColumn(): string
{
$locale = (string) app()->getLocale();
return in_array($locale, ['de', 'en', 'es', 'fr', 'it', 'ru'], true) ? $locale : 'de';
}
/**
* Sprache "de" schließt Datensätze ohne gesetzte Sprache ein, weil das System
* dort auf die Fallback-Sprache zurückfällt.
*/
private function applyLanguage(Builder $query, string $column, ?string $language): void
{
if ($language === null) {
return;
}
if ($language === config('app.fallback_locale', 'de')) {
$query->where(fn ($q) => $q->where($column, $language)->orWhereNull($column)->orWhere($column, ''));
return;
}
$query->where($column, $language);
}
private function applyPurchase(Builder $query, NewsletterListFilter $filter): void
{
$months = $filter->purchaseMonths();
if ($months !== null) {
$query->whereHas('shopping_orders', fn ($q) => $q
->where('paid', 1)
->where('created_at', '>=', now()->subMonths($months)));
return;
}
match ($filter->purchase) {
NewsletterListFilter::PURCHASE_HAS_BUYED => $query->where('shopping_users.has_buyed', 1),
NewsletterListFilter::PURCHASE_NONE => $query->where(fn ($q) => $q->whereNull('shopping_users.has_buyed')->orWhere('shopping_users.has_buyed', 0)),
NewsletterListFilter::PURCHASE_ACTIVE_ABO => $query->whereIn(
'shopping_users.id',
UserAbo::query()->activeAbo()->whereNotNull('shopping_user_id')->select('shopping_user_id')
),
default => null,
};
}
private function applyCreatedRange(Builder $query, string $column, NewsletterListFilter $filter): void
{
if ($filter->createdFrom !== null) {
$query->where($column, '>=', $filter->createdFrom);
}
if ($filter->createdTo !== null) {
$query->where($column, '<=', $filter->createdTo);
}
}
private function applyCompany(Builder $query, string $column, string $company): void
{
if ($company === NewsletterListFilter::COMPANY_WITH) {
$query->whereNotNull($column)->where($column, '!=', '');
} elseif ($company === NewsletterListFilter::COMPANY_WITHOUT) {
$query->where(fn ($q) => $q->whereNull($column)->orWhere($column, ''));
}
}
}

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Gespeicherte Newsletter-Listen (Filterkombinationen) und das Protokoll
* aller Exporte, damit nachvollziehbar bleibt, wer wann welche Adressen
* exportiert hat.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('newsletter_lists', function (Blueprint $table) {
$table->id();
$table->string('name', 120);
$table->string('source', 20);
$table->json('filters');
$table->unsignedBigInteger('created_by')->nullable()->index();
$table->timestamp('last_exported_at')->nullable();
$table->unsignedInteger('last_row_count')->nullable();
$table->timestamps();
});
Schema::create('newsletter_list_exports', function (Blueprint $table) {
$table->id();
$table->foreignId('newsletter_list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('format', 10);
$table->unsignedInteger('row_count');
$table->string('filename', 190);
$table->json('filters');
$table->json('stats')->nullable();
$table->timestamp('created_at')->nullable()->index();
});
}
public function down(): void
{
Schema::dropIfExists('newsletter_list_exports');
Schema::dropIfExists('newsletter_lists');
}
};

View file

@ -0,0 +1,394 @@
# Newsletter-Listen (Admin) – Entwicklungsplan
**Stand:** 11.09.2026 · **Status:** Phase 1 + 2 umgesetzt (siehe Umsetzungsstand)
**Ticket:** Export von E-Mail-Listen (Kunden + Berater) für den Newsletter-Versand. Vorlage: `Kundenliste_Deutsch_Stand 02.07.2026.csv/.xlsx` in diesem Ordner.
---
## Umsetzungsstand (11.09.2026)
| Phase | Thema | Status | Anmerkungen |
|-------|-------|--------|-------------|
| 1 | Filter, Vorschau, XLSX/CSV, Navigation, Tests | ✅ Umgesetzt | 20 Pest-Tests grün (`tests/Feature/Newsletter/`) |
| 2 | Gespeicherte Listen, Export-Protokoll | ✅ Umgesetzt | Migration `2026_09_11_120000_create_newsletter_lists_tables`, 9 weitere Tests |
| 3 | Sperrliste, Abmelde-Import, `subscribed_at` | ⏳ Offen | `subscribed_at` laut Rückmeldung gewünscht |
| 4 | Schnittstelle rapidmail | ⏳ Offen | Tool steht fest: **rapidmail** (Import-API vorhanden) |
### Getroffene Entscheidungen (Antworten auf Abschnitt 10)
1. **Opt-in-Default = „alle"**, Opt-in wird als Spalte „Newsletter" (ja / nein / Vertrag) ausgegeben; Sortierung passiert in rapidmail. Warnhinweis auf der Seite bleibt.
2. **Berater** laufen über den Beratervertrag mit (Spalte „Newsletter" = Vertrag), kein neues Feld.
3. **„Deutsch" = Sprache `de`**, Land frei wählbar. Datensätze ohne Sprache zählen als `de`.
4. **Rechnungsadresse** (`billing_*`).
5. **Inaktive Berater** sind über den Status-Filter wählbar, Spalte „Status" (aktiv / inaktiv) im Export.
6. **Berater immer separat**, Kunden nur ohne Berater-Login (`auth_user_id` leer).
7. **Kaufstatus**: hat gekauft / nie gekauft / 3 / 6 / 12 Monate / aktives Abo als eigene Option.
8. Newsletter-Tool: **rapidmail**.
9. `subscribed_at` → Phase 3.
10. Homeparty-Kunden bleiben draußen.
### Umgesetzte Dateien (Phase 1)
| Datei | Zweck |
|---|---|
| `app/Services/Newsletter/NewsletterListFilter.php` | Wertobjekt für alle Filter, Defaults, erlaubte Werte, Cache-Key |
| `app/Services/Newsletter/NewsletterListService.php` | Queries (Joins statt Eager Loading), Pflichtausschlüsse, Dedupe in einem Durchlauf, Export-Spalten |
| `app/Services/Newsletter/NewsletterListResult.php` | Zeilen + Kennzahlen |
| `app/Http/Requests/Newsletter/NewsletterListFilterRequest.php` | Validierung, `authorize()` nur Admin |
| `app/Http/Controllers/Admin/NewsletterListController.php` | `index`, `datatable` (Yajra auf Collection, 5-Minuten-Cache je Filter), `export` (xlsx via `xExport`, csv via Stream) |
| `routes/domains/crm.php` | `admin_newsletter_lists`, `…_datatable`, `…_export/{xlsx|csv}` im `admin`-Block |
| `resources/views/admin/newsletter/index.blade.php` | Filter-Card, Kennzahlen, Hinweise, DataTable, Export-Buttons |
| `resources/views/layouts/includes/layout-sidenav.blade.php` | Menüpunkt „Newsletter-Listen" unter „Kunden" |
| `resources/lang/{de,en,es,fr}/newsletter.php`, `navigation.php` | Labels |
| `tests/Feature/Newsletter/NewsletterListServiceTest.php`, `NewsletterListControllerTest.php` | Ausschlüsse, Dedupe, alle Filter, Export-Format, Zugriff, Validierung |
### Umgesetzte Dateien (Phase 2)
| Datei | Zweck |
|---|---|
| `database/migrations/2026_09_11_120000_create_newsletter_lists_tables.php` | Tabellen `newsletter_lists` (Name, Quelle, Filter-JSON, Ersteller, letzter Export) und `newsletter_list_exports` (Protokoll: Benutzer, Format, Anzahl, Dateiname, Filter-Snapshot, Kennzahlen) |
| `app/Models/NewsletterList.php`, `app/Models/NewsletterListExport.php` | Models mit Casts und Relationen; `NewsletterList::toFilter()` / `applyFilter()` |
| `app/Http/Requests/Newsletter/StoreNewsletterListRequest.php` | Filter-Regeln + Pflichtfeld `name` |
| `NewsletterListController` | neu: `store`, `update`, `destroy`, `load`, `saved` + Datatable, `exports` + Datatable, `exportSaved`; jeder Download läuft über `download()` und wird protokolliert |
| `NewsletterListService::describe()` | lesbare Filter-Kurzfassung für Übersicht und Protokoll |
| `resources/views/admin/newsletter/saved.blade.php`, `exports.blade.php` | Übersichten (Yajra) |
| `index.blade.php` | Banner „Geladene Liste", Formular „Als neue Liste speichern" / „Änderungen speichern" (kopiert die aktuellen Filterfelder per JS), `list_id` wandert in Vorschau und Export |
| Sidenav | Untermenü „Newsletter-Listen": Liste erstellen / Gespeicherte Listen / Export-Protokoll |
| `tests/Feature/Newsletter/NewsletterListSavedTest.php` | Speichern, Validierung, Aktualisieren, Laden, Protokoll (gespeichert + ad hoc), Löschen, Datatables, Filter-Beschreibung |
**Ablauf:** Filter einstellen → Name eingeben → „Als neue Liste speichern" → Liste ist geladen (Banner). Export aus der Filter-Seite oder direkt aus der Übersicht; beides protokolliert Benutzer, Zeitpunkt, Format, Filter und Adresszahl und aktualisiert „Letzter Export" der Liste. Ad-hoc-Exporte ohne Liste erscheinen im Protokoll als „ohne gespeicherte Liste". Beim Löschen einer Liste bleiben die Protokolleinträge erhalten (Verknüpfung wird auf NULL gesetzt).
### Messwerte auf der Live-Datenbank (11.09.2026)
| Liste | Rohdaten | Duplikate | Ungültig | Ergebnis | Dauer |
|---|---|---|---|---|---|
| Kunden, de, alle | 73.927 | 32.208 | 88 | 41.631 | 0,5 s |
| Kunden, de, nur Opt-in | 7.070 | 897 | 33 | 6.140 | 0,3 s |
| Berater aktiv, de | 580 | 0 | 0 | 580 | < 0,1 s |
| Kunden mit aktivem Abo | 447 | 1 | 1 | 445 | < 0,1 s |
Die hohe Duplikatzahl kommt von Gastbestellungen: dieselbe E-Mail liegt oft als mehrere `shopping_users`-Zeilen vor. Es gewinnt die Zeile mit Opt-in, dann mit Kauf, dann die neueste.
### Abweichungen vom ursprünglichen Plan
- **Kein `Spatie\Html`** im Filterformular: keine einzige Admin-View nutzt das Paket, das Formular folgt dem Blade-/Bootstrap-Muster der Kundenliste (`admin/customer/index.blade.php`).
- **Dedupe ohne `Collection::sortBy`**: die Mehrfach-Sortierung über 74k Zeilen kostete 7 s, der Ein-Pass-Vergleich (`outranks`) 0,3 s.
- **Test-Schema vs. Produktion**: In der SQLite-Testdatenbank sind `faker_mail`, `subscribed` und `auth_user_id` NOT NULL, in Produktion stehen dort NULL-Werte (z. B. `faker_mail` bei 4.281 Abonnenten). Der Service behandelt NULL wie 0, die Tests decken den NULL-Fall deshalb nicht ab.
---
## 1. Ziel
Ein neuer Admin-Bereich **„Newsletter-Listen"**, in dem ein Admin per Filter eine Empfängerliste
zusammenstellt, in der Vorschau prüft und als **XLSX** (Vorlage) oder **CSV** herunterlädt.
- Quellen: **Kunden** (`shopping_users`) und **Berater** (`users` + `user_accounts`)
- Doppelte E-Mails fallen automatisch raus
- Gesperrte, gelöschte, anonymisierte, Test- und Fake-Datensätze fallen automatisch raus
- Später: gespeicherte Listen-Definitionen, Sperrliste, API-Schnittstelle für den Newsletter-Dienst
---
## 2. Analyse der Vorlage (CSV vom 02.07.2026)
| Kennzahl | Wert |
|---|---|
| Zeilen | 10.441 |
| Eindeutige E-Mails (normalisiert) | 10.022 |
| Doppelte E-Mails | 394 Adressen, 417 überzählige Zeilen (18 davon nur durch Groß-/Kleinschreibung verschieden) |
| Ungültige Zeilen | 3 (`#`, Leerzeichen in Adresse, Fußzeile „07.02.26 Seite 6") |
| Berater-Spalte leer oder `?` | 2.889 |
| Firma gefüllt | 884 |
| Nicht-DE-PLZ (AT, CH, FR, LU) | ca. 960 |
**Spalten der Vorlage:** `Emailadresse; Vorname; Nachname; Anrede; Straße; PLZ; Wohnort; Firma; Berater`
Anrede ist Freitext („Frau", „frau", „Herr Dr.", „Herrr", „Frau/Herr") → wir liefern sie künftig normalisiert.
### Abgleich mit der Datenbank (Stand heute)
| Ergebnis | Anzahl |
|---|---|
| CSV-Adressen, die in `shopping_users` existieren | 9.995 von 10.022 |
| … davon mit Newsletter-Opt-in (`subscribed = 1`) | **2.885** |
| … davon mit mindestens einem Kauf (`has_buyed = 1`) | 3.688 |
| CSV-Adressen, die ein Berater-Login sind (`users.email`) | 254 |
| CSV-Adressen, die nirgends im System sind | 29 |
| Opt-in-Adressen in der DB, die **nicht** in der CSV stehen | 3.493 |
**Wichtigste Erkenntnis:** Die aktuelle Liste wurde offenbar aus **allen** Kunden mit echter E-Mail gezogen,
nicht aus den Newsletter-Abonnenten. Rund **7.100 Adressen haben keinen Opt-in im System**. Das muss
vor der Umsetzung entschieden werden (siehe Offene Fragen, Punkt 1) – der Export-Filter kann beides,
aber der Default sollte rechtssicher sein.
---
## 3. Datenquellen im System
### 3.1 Kunden – `shopping_users` (`App\Models\ShoppingUser`)
| Export-Spalte | DB-Feld | Anmerkung |
|---|---|---|
| E-Mail | `billing_email` | normalisieren: `LOWER(TRIM())` |
| Vorname / Nachname | `billing_firstname`, `billing_lastname` | |
| Anrede | `billing_salutation` | `mr` / `ms` / `di` → „Herr" / „Frau" / „Divers" (über `HTMLHelper::getSalutationLang`) |
| Straße | `billing_address` (+ `billing_address_2`) | |
| PLZ / Ort | `billing_zipcode`, `billing_city` | |
| Land | `billing_country_id` → `countries` | Ländername je Sprache (`Country::getLocated`) plus ISO-Code |
| Firma | `billing_company` | |
| Berater | `member_id` → `users` → `user_accounts` | Name des Beraters, zusätzlich Berater-Nr. |
| Sprache | `language` (char 2) | de / es / en / fr |
| Opt-in | `subscribed` (bool) | **einziges echtes Opt-in-Feld im System**; nur wenn `has_buyed = 1` erlaubt |
| Kunde-Nr. | `number` | |
Zahlen heute: 81.089 Kunden ohne Berater-Login, davon 8.919 mit `subscribed = 1`,
davon 6.376 eindeutige echte E-Mail-Adressen (nach Pflichtausschlüssen, siehe Abschnitt 4).
### 3.2 Berater – `users` + `user_accounts` (`App\User`, `App\Models\UserAccount`)
| Export-Spalte | DB-Feld |
|---|---|
| E-Mail | `users.email` |
| Vorname / Nachname / Anrede | `user_accounts.first_name`, `last_name`, `salutation` |
| Straße / PLZ / Ort / Land | `user_accounts.address`, `zipcode`, `city`, `country_id` |
| Firma | `user_accounts.company` |
| Sponsor (= „Berater"-Spalte) | `users.m_sponsor` → `users` → `user_accounts` |
| Sprache | `users.lang` |
| Level | `users.m_level` → `user_levels.name` |
| Status | `users.active`, `blocked`, `confirmed`, `admin` |
Zahlen heute: 895 Berater (`admin < 5`), davon 609 aktiv und nicht gesperrt.
**Berater haben kein Newsletter-Opt-in-Feld.** Rechtsgrundlage wäre der Beratervertrag
(`user_accounts.accepted_contract`, `data_protection`). Siehe Offene Fragen, Punkt 2.
### 3.3 Weitere Tabellen (nicht als Quelle vorgesehen)
- `homeparty_users` – gleiche Struktur inkl. `subscribed`, aktuell 0 Abonnenten → vorerst ignorieren, Quelle optional in Phase 2
- `customers` – nur Portal-Login (Magic Link), 1.237 Zeilen, kein Opt-in → nur zur Dedupe-Prüfung interessant
- Leads existieren nicht als eigene Tabelle (`admin_leads` listet `users`)
---
## 4. Pflicht-Ausschlüsse (immer aktiv, nicht abwählbar)
**Kunden:**
1. `deleted_at IS NULL` und `user_deleted_at IS NULL`
2. `auth_user_id IS NULL` (sonst ist es der Shop-Datensatz eines Beraters → kommt über die Berater-Quelle)
3. `faker_mail != 1` – **Achtung:** Feld ist bei ~4.300 Abonnenten `NULL`, Filter muss `IS NULL OR = 0` sein
4. E-Mail nicht `%@dsgvo.invalid` (DSGVO-Anonymisierung aus `GdprDeletionService`) und nicht `%faker@mivita.care`
5. `mode IS NULL OR mode = 'live'` (keine `dev` / `prev`-Datensätze)
6. E-Mail syntaktisch gültig (`filter_var(FILTER_VALIDATE_EMAIL)`), kein Leerzeichen
**Berater:**
1. `deleted_at IS NULL`, `pre_deleted_at IS NULL`
2. `blocked = 0`
3. `admin < 5` (keine System-Accounts), `test_mode = 0`
4. E-Mail syntaktisch gültig
**Beide:** Adresse steht nicht auf der Sperrliste (Phase 3, siehe Abschnitt 9).
---
## 5. Aufbau der Admin-Seite
**Navigation:** Neuer Eintrag im ADMIN-Block der Sidenav (`layout-sidenav.blade.php`), direkt unter „Kunden":
Icon `ion ion-md-mail`, Label `navigation.newsletter_lists` („Newsletter-Listen"), sichtbar für `isAdmin()`.
**Route:** `/admin/newsletter-lists` innerhalb der bestehenden `['middleware' => ['admin']]`-Gruppe in `routes/domains/crm.php`.
### Seitenlayout (`layouts.layout-2`, Bootstrap 4 / Appwork)
```
┌──────────────────────────────────────────────────────────────────────┐
│ Newsletter-Listen │
├──────────────────────────────────────────────────────────────────────┤
│ [Filter-Card] │
│ Quelle: (•) Kunden ( ) Berater ( ) Beide │
│ Sprache: [de ▾] Land: [alle ▾] Opt-in: [nur mit Opt-in ▾] │
│ Kaufstatus: [alle ▾] Berater: [alle ▾] Angelegt: [von] [bis] │
│ ── Berater-Filter (nur bei Quelle Berater/Beide) ── │
│ Status: [aktiv ▾] Level: [alle ▾] │
│ [Vorschau aktualisieren] │
├──────────────────────────────────────────────────────────────────────┤
│ [Ergebnis-Card] │
│ Treffer: 6.376 eindeutige Adressen (Kunden 6.243 · Berater 133 · │
│ Duplikate entfernt 1.204 · ausgeschlossen 2.543) │
│ [⬇ XLSX] [⬇ CSV] (Phase 2: [💾 Liste speichern]) │
│ ┌ Yajra DataTable (serverside) ─────────────────────────────────┐ │
│ │ E-Mail | Name | Anrede | Ort | Land | Sprache | Berater | Quelle│ │
│ └───────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
Die Vorschau zeigt genau die Zeilen, die im Export landen (inkl. Dedupe). Die Kennzahlen-Zeile macht
transparent, warum die Zahl kleiner ist als „Kunden gesamt".
---
## 6. Filter (Vorschlag)
| Filter | Optionen | Default | Quelle |
|---|---|---|---|
| **Quelle** | Kunden / Berater / Beide | Kunden | – |
| **Sprache** | alle / de / es / en / fr | de | `shopping_users.language`, `users.lang` |
| **Land** | alle / Mehrfachauswahl (DE, AT, CH, ES, …) | alle | `billing_country_id`, `user_accounts.country_id` |
| **Opt-in** | nur mit Opt-in / alle (mit Warnhinweis) | nur mit Opt-in | `subscribed` (bei Beratern: immer „Vertrag") |
| **Kaufstatus** | alle / hat gekauft / gekauft in den letzten X Monaten / hat aktives Abo | alle | `has_buyed`, `shopping_orders`, `abos` |
| **Berater (Zuordnung)** | alle / bestimmter Berater / ohne Berater | alle | `member_id` bzw. `m_sponsor` |
| **Angelegt von–bis** | Datum | leer | `created_at` |
| **Firma** | alle / nur mit Firma / nur ohne Firma | alle | `billing_company` |
| Berater: **Status** | aktiv / inaktiv / alle | aktiv | `active`, `confirmed` |
| Berater: **Level** | alle / Mehrfachauswahl | alle | `m_level` |
| Berater: **Rolle** | Berater / inkl. Admins | Berater | `admin` |
Die Filter werden wie bei der Kundenliste über `set_user_attr()` / `get_user_attr()` gemerkt.
**Hinweis zu „Deutsch":** Die Vorlage heißt „Kundenliste Deutsch", enthält aber DE, AT, CH, FR, LU.
Gemeint ist offenbar die **Sprache** (Newsletter auf Deutsch), nicht das Land. Deshalb ist Sprache ein
eigener Filter mit Default `de`, das Land bleibt frei wählbar. Der Export bekommt beide Spalten.
---
## 7. Dedupe-Regeln
1. Schlüssel: `LOWER(TRIM(email))`
2. Priorität bei Kollision:
1. **Berater-Datensatz** gewinnt vor Kunden-Datensatz (bessere Stammdaten, Vertragsbasis)
2. Innerhalb der Kunden: Datensatz mit `subscribed = 1` vor ohne, dann `has_buyed = 1`, dann neueste `id` (Muster aus `User/CustomerController.php:259`)
3. Die Spalte **„Quelle"** (`Kunde` / `Berater`) im Export zeigt, welcher Datensatz gewonnen hat
4. Dedupe erfolgt in PHP nach dem Laden (Collection `->unique()` nach Priorität sortiert), nicht per SQL,
damit beide Tabellen gemeinsam dedupliziert werden können. Bei ~10k Zeilen unkritisch; Chunking über `cursor()`.
Heute im System: 133 Berater-Adressen stehen zusätzlich als abonnierter Kunde drin, 1.636 Abonnenten haben Fake-Mails.
---
## 8. Export-Format
**Spalten (Reihenfolge wie Vorlage, plus Ergänzungen am Ende):**
| # | Spalte | Kommentar |
|---|---|---|
| 1 | Emailadresse | normalisiert (klein, getrimmt) |
| 2 | Vorname | getrimmt |
| 3 | Nachname | |
| 4 | Anrede | normalisiert: Frau / Herr / Divers / leer |
| 5 | Straße | Adresse + Zusatz |
| 6 | PLZ | ohne Länderpräfix (Vorlage hatte „A-6600", „CH-9542") |
| 7 | Wohnort | |
| 8 | Firma | |
| 9 | Berater | voller Name des zugeordneten Beraters/Sponsors, sonst leer (kein `?`) |
| 10 | Land | ISO-Code (DE, AT, CH …) |
| 11 | Sprache | de / es / … |
| 12 | Quelle | Kunde / Berater |
| 13 | Kunden-Nr. / Berater-Nr. | `number` bzw. `m_account` |
| 14 | Berater-Nr. | `m_account` des zugeordneten Beraters |
| 15 | Opt-in | ja / Vertrag |
Spalten 10–15 sind der Mehrwert gegenüber der Vorlage; falls der Newsletter-Dienst nur die 9 Spalten
verträgt, gibt es ein Häkchen „Nur Vorlage-Spalten".
- **XLSX** über `Maatwebsite\Excel` mit der bestehenden `App\Exports\xExport`-Klasse (Muster `PaymentSalesController.php:145`)
- **CSV** per `response()->streamDownload()`, Trennzeichen `;`, UTF-8 mit BOM (Excel-kompatibel), inkl. CSV-Injection-Schutz wie `BackofficeStatisticsController::csvSafeRow()`
- Dateiname: `newsletter_{quelle}_{sprache}_{YYYY-MM-DD}.xlsx`
---
## 9. Technische Umsetzung
### Phase 1 – Filter, Vorschau, Export (MVP)
| Datei | Zweck |
|---|---|
| `app/Services/Newsletter/NewsletterListService.php` | Baut die beiden Queries (Kunden, Berater), wendet Pflichtausschlüsse + Filter an, dedupliziert, liefert einheitliche Zeilen-DTOs und Kennzahlen |
| `app/Services/Newsletter/NewsletterListFilter.php` | Wertobjekt für die Filter (aus Request gebaut, validiert) |
| `app/Http/Requests/Newsletter/NewsletterListFilterRequest.php` | Validierung der Filterparameter (Sprache, Land-IDs, Datumsbereich …) |
| `app/Http/Controllers/Admin/NewsletterListController.php` | `index()`, `datatable()` (Yajra, serverside auf die deduplizierte Collection), `export()` (xlsx/csv) |
| `routes/domains/crm.php` | `admin.newsletter.index`, `admin.newsletter.datatable`, `admin.newsletter.export` im `admin`-Block |
| `resources/views/admin/newsletter/index.blade.php` + `_filter.blade.php` | Filterformular mit `Spatie\Html`, Kennzahlen, DataTable |
| `resources/views/layouts/includes/layout-sidenav.blade.php` | Menüpunkt |
| `resources/lang/{de,en,es,fr}/navigation.php`, neue `newsletter.php` | Labels |
| `app/Exports/NewsletterListExport.php` | optional: eigene Export-Klasse statt `xExport`, falls Spaltenformatierung (PLZ als Text) nötig |
**Datatable-Hinweis:** Weil die Dedupe über zwei Tabellen in PHP passiert, ist eine reine Eloquent-Datatable
nicht möglich. Vorgehen: `DataTables::collection($service->rows($filter))` mit Cache der Ergebnis-Collection
(Cache-Key aus Filter-Hash, TTL 5 Min), damit Blättern/Sortieren nicht jedes Mal neu rechnet.
Alternative bei Performance-Problemen: UNION-Query mit Fensterfunktion (`ROW_NUMBER() OVER (PARTITION BY email …)`)
in MySQL 8 – dann bleibt Yajra serverside. Entscheidung nach erstem Messwert.
### Phase 2 – Gespeicherte Listen & Export-Protokoll
- Tabelle `newsletter_lists`: `id, name, source, filters (json), created_by, last_exported_at, row_count`
- Tabelle `newsletter_list_exports`: `id, newsletter_list_id, user_id, format, row_count, filename, created_at`
- Admin kann eine Filterkombination unter Namen speichern („Newsletter DE Kunden", „Berater ES") und mit einem Klick erneut exportieren
- Übersichts-Datatable aller gespeicherten Listen mit letztem Export und Trefferzahl
- Grundlage für die spätere Schnittstelle: jede Liste hat eine feste ID
### Phase 3 – Sperrliste & Abmeldung
- Tabelle `newsletter_blocklist`: `email (unique, normalisiert), reason (unsubscribed / bounce / manual / gdpr), source, created_by, created_at`
- Admin-Maske: manuell sperren, CSV-Import von Abmeldungen/Bounces aus dem Newsletter-Tool
- Pflichtausschluss in `NewsletterListService`
- `GdprDeletionService` trägt anonymisierte Adressen automatisch ein
- Optional: Abmelde-Link-Handling im Kundenportal setzt `subscribed = 0` **und** Sperrliste
### Phase 4 – Schnittstelle (später)
- API-Endpunkt `GET /api/newsletter/lists/{id}/contacts` (Passport, Client Credentials, eigener Scope `newsletter:read`)
- Liefert dieselben Zeilen wie der Export als JSON, paginiert, mit `updated_since`-Parameter
- Alternativ Push per Artisan-Command (`newsletter:sync {list}`) im Scheduler, wenn das Newsletter-Tool eine Import-API hat
- Erst festlegen, wenn das Newsletter-Tool bekannt ist (Brevo, Mailchimp, CleverReach … haben alle andere Feldnamen)
### Tests (Pest, `tests/Feature/Admin/NewsletterList*.php`)
- Zugriff: Admin ja, VIP/Berater/Gast nein
- Pflichtausschlüsse: gelöscht, DSGVO-anonymisiert, Fake-Mail (`NULL` und `1`), `mode = dev`, gesperrter Berater, `auth_user_id` gesetzt
- Dedupe: gleiche Mail in Kunden + Berater → Berater gewinnt; gleiche Mail zweimal als Kunde → Opt-in-Datensatz gewinnt; Groß-/Kleinschreibung
- Filter: Sprache, Land, Opt-in, Kaufstatus, Berater-Zuordnung, Datumsbereich
- Export: XLSX-Download liefert erwartete Kopfzeile und Zeilenzahl; CSV mit `;` und BOM; Injection-Schutz
- Anrede-Mapping `mr/ms/di`
---
## 10. Offene Fragen (vor Umsetzung klären)
1. **Rechtsgrundlage Kunden ohne Opt-in.** Die aktuelle Liste enthält ~7.100 Adressen ohne `subscribed = 1`.
Vorschlag: Default-Filter „nur mit Opt-in", Option „alle" nur mit Warnhinweis und Protokollierung (wer, wann, wie viele).
Oder: Opt-in-Flag im System korrigieren, falls die Abonnenten im Newsletter-Tool die Wahrheit sind (einmaliger Import).
Ich würde als die default alle machen und es in der Liste zusätzlich mit aufführen,
Eine Sortierung findet wahrscheinlich im Newsletter Tool statt, und hier sind schon entsprechende Adressen gekennzeichnet.
Ein späterer Abgleich ist geplant, aber zuerst die Export listen
2. **Berater-Opt-in.** Berater haben kein Newsletter-Feld. Reicht der Beratervertrag (Info-Mails an aktive Berater)?
Falls nein: neues Feld `users.newsletter` + Checkbox im Berater-Profil. Kleiner Zusatzaufwand, sauberer.
Ja, ist im Beratervertrag aufgeführt und wird die nicht berücksichtigt, Es gibt die Möglichkeit, sich abzumelden.
3. **Sprache vs. Land.** Annahme: „Deutsch" = Sprache `de`, Länder frei. Korrekt?
ja
4. **Rechnungs- oder Lieferadresse?** Vorlage nutzt Rechnungsadresse (`billing_*`). Vorschlag: bleibt so.
ja
5. **Inaktive Berater.** Default nur `active = 1`. Sollen ehemalige Berater (inaktiv, nicht gesperrt) wählbar sein?
Ja wählbar und auch zusätzlich in der Liste mit ausgeben
6. **Berater-Datensätze als Kunden.** 133 Berater-Mails stehen zusätzlich als abonnierter Kunde in `shopping_users`.
Bei Quelle „nur Kunden" fallen die raus (Regel `auth_user_id IS NULL`) – gewünscht, oder sollen sie als Kunde mitlaufen?
Nein, Berater immer separat Kunden immer nur die keine Berater sind.
7. **Kaufstatus-Filter.** Welche Zeiträume sind sinnvoll (3 / 6 / 12 Monate)? Aktives Abo als eigener Filter?
Ja wie in deinem Vorschlag auch Abo als eigener Filter
8. **Newsletter-Tool.** Welches Tool wird genutzt? Bestimmt Spaltennamen und die Schnittstelle in Phase 4.
der Kunde nutzt derzeit rapidmail
9. **Opt-in-Zeitpunkt.** `subscribed` hat keinen Timestamp. Für Nachweispflicht ggf. `subscribed_at` ergänzen (Phase 3).
ja
10. **Homeparty-Kunden** (`homeparty_users`, heute 0 Abonnenten) als Quelle vorsehen?
Nein, das sind Letztendlich Kunden und Berater diese würde ich hier nicht mit reinnehmen
---
## 11. Aufwandsschätzung
| Phase | Inhalt | Aufwand |
|---|---|---|
| 1 | Service, Filter, Vorschau, XLSX/CSV, Navigation, Tests | 2–3 Tage |
| 2 | Gespeicherte Listen, Export-Protokoll | 1 Tag |
| 3 | Sperrliste, Import von Abmeldungen, DSGVO-Kopplung | 1 Tag |
| 4 | API / Sync-Command | 1–2 Tage (abhängig vom Tool) |
Phase 1 ist unabhängig lauffähig und ersetzt die manuelle Listenerstellung sofort.

View file

@ -94,4 +94,8 @@ return [
'team_customers' => 'Team Kunden',
'payment_monitor' => 'Payment Monitor',
'payment_monitor_management' => 'Payment Monitor GF',
'newsletter_lists' => 'Newsletter-Listen',
'newsletter_create' => 'Liste erstellen',
'newsletter_saved' => 'Gespeicherte Listen',
'newsletter_exports' => 'Export-Protokoll',
];

View file

@ -0,0 +1,158 @@
<?php
return [
'title' => 'Newsletter-Listen',
'intro' => 'Empfängerliste aus Kunden und Beratern zusammenstellen, prüfen und für rapidmail exportieren. Gelöschte, anonymisierte, Fake- und Test-Datensätze werden immer ausgeschlossen, doppelte E-Mail-Adressen automatisch entfernt.',
'filter' => [
'source' => 'Quelle',
'language' => 'Sprache',
'country' => 'Land',
'optin' => 'Newsletter-Opt-in',
'purchase' => 'Kaufstatus',
'member' => 'Zugeordneter Berater / Sponsor',
'created_from' => 'Angelegt von',
'created_to' => 'Angelegt bis',
'company' => 'Firma',
'consultant_status' => 'Berater-Status',
'level' => 'Karriere-Level',
'include_admins' => 'Admin-Accounts einbeziehen',
'all' => 'alle',
'none_member' => 'ohne Zuordnung',
'consultant_section' => 'Berater-Filter',
'refresh' => 'Vorschau aktualisieren',
'reset' => 'Filter zurücksetzen',
],
'source_option' => [
'customers' => 'Kunden',
'consultants' => 'Berater',
'both' => 'Kunden und Berater',
],
'source' => [
'customer' => 'Kunde',
'consultant' => 'Berater',
],
'optin_option' => [
'all' => 'alle (Opt-in wird als Spalte ausgegeben)',
'subscribed' => 'nur mit Opt-in',
'unsubscribed' => 'nur ohne Opt-in',
],
'optin_value' => [
'yes' => 'ja',
'no' => 'nein',
'contract' => 'Vertrag',
],
'purchase_option' => [
'all' => 'alle',
'has_buyed' => 'hat gekauft',
'none' => 'noch nie gekauft',
'months_3' => 'gekauft in den letzten 3 Monaten',
'months_6' => 'gekauft in den letzten 6 Monaten',
'months_12' => 'gekauft in den letzten 12 Monaten',
'active_abo' => 'hat aktives Abo',
],
'company_option' => [
'all' => 'alle',
'with' => 'nur mit Firma',
'without' => 'nur ohne Firma',
],
'consultant_status_option' => [
'active' => 'nur aktive',
'inactive' => 'nur inaktive',
'all' => 'alle',
],
'status_value' => [
'active' => 'aktiv',
'inactive' => 'inaktiv',
],
'stats' => [
'total' => 'eindeutige Adressen',
'customers' => 'Kunden',
'consultants' => 'Berater',
'duplicates' => 'Duplikate entfernt',
'invalid' => 'ungültige Adressen',
'loading' => 'Liste wird berechnet …',
],
'hint' => [
'optin_all' => 'Die Liste enthält auch Kunden ohne Newsletter-Opt-in. Die Spalte „Newsletter" kennzeichnet den Status je Adresse.',
'consultants_contract' => 'Berater haben kein eigenes Opt-in; Grundlage ist der Beratervertrag (Spalte „Newsletter" = Vertrag).',
],
'export' => [
'email' => 'Emailadresse',
'firstname' => 'Vorname',
'lastname' => 'Nachname',
'salutation_label' => 'Anrede',
'street' => 'Straße',
'zipcode' => 'PLZ',
'city' => 'Wohnort',
'company' => 'Firma',
'consultant' => 'Berater',
'country' => 'Land',
'language' => 'Sprache',
'source_label' => 'Quelle',
'number' => 'Nummer',
'consultant_number' => 'Berater-Nr.',
'optin_label' => 'Newsletter',
'status_label' => 'Status',
'created_at' => 'Angelegt',
'button_xlsx' => 'Export XLSX',
'button_csv' => 'Export CSV',
'template_only' => 'Nur die 9 Spalten der Vorlage exportieren',
],
'table' => [
'email' => 'E-Mail',
'name' => 'Name',
'salutation' => 'Anrede',
'zipcode' => 'PLZ',
'city' => 'Ort',
'country' => 'Land',
'language' => 'Sprache',
'consultant' => 'Berater / Sponsor',
'source' => 'Quelle',
'optin' => 'Newsletter',
'status' => 'Status',
'created' => 'Angelegt',
],
'saved' => [
'title' => 'Gespeicherte Listen',
'current' => 'Geladene Liste',
'name' => 'Name',
'name_placeholder' => 'z. B. Newsletter DE Kunden',
'save_new' => 'Als neue Liste speichern',
'save_changes' => 'Änderungen in dieser Liste speichern',
'summary' => 'Filter',
'source' => 'Quelle',
'last_export' => 'Letzter Export',
'last_count' => 'Adressen',
'created_by' => 'Angelegt von',
'created_at' => 'Angelegt',
'actions' => 'Aktionen',
'load' => 'Filter laden',
'delete' => 'Löschen',
'confirm_delete' => 'Liste wirklich löschen? Das Export-Protokoll bleibt erhalten.',
'stored' => 'Liste gespeichert.',
'updated' => 'Liste aktualisiert.',
'deleted' => 'Liste gelöscht.',
'intro' => 'Gespeicherte Filterkombinationen. Über „Filter laden" wird die Liste in die Vorschau übernommen, der Export nutzt die hinterlegten Filter.',
],
'exports' => [
'title' => 'Export-Protokoll',
'intro' => 'Jeder Download wird mit Benutzer, Zeitpunkt, Filter und Anzahl der Adressen festgehalten.',
'date' => 'Datum',
'user' => 'Benutzer',
'list' => 'Liste',
'adhoc' => 'ohne gespeicherte Liste',
'summary' => 'Filter',
'format' => 'Format',
'rows' => 'Adressen',
'filename' => 'Datei',
],
'validation' => [
'source' => 'Ungültige Quelle.',
'option' => 'Ungültiger Filterwert.',
'member' => 'Ungültige Berater-Auswahl.',
'date' => 'Ungültiges Datum.',
'name' => 'Bitte einen Namen angeben (max. 120 Zeichen).',
'list' => 'Liste nicht gefunden.',
'date_range' => 'Das Enddatum darf nicht vor dem Startdatum liegen.',
],
];

View file

@ -94,4 +94,8 @@ return [
'team_customers' => 'Team Customers',
'payment_monitor' => 'Payment Monitor',
'payment_monitor_management' => 'Payment Monitor (Mgmt)',
'newsletter_lists' => 'Newsletter lists',
'newsletter_create' => 'Create list',
'newsletter_saved' => 'Saved lists',
'newsletter_exports' => 'Export log',
];

View file

@ -0,0 +1,158 @@
<?php
return [
'title' => 'Newsletter lists',
'intro' => 'Build a recipient list from customers and consultants, review it and export it for rapidmail. Deleted, anonymised, fake and test records are always excluded; duplicate e-mail addresses are removed automatically.',
'filter' => [
'source' => 'Source',
'language' => 'Language',
'country' => 'Country',
'optin' => 'Newsletter opt-in',
'purchase' => 'Purchase status',
'member' => 'Assigned consultant / sponsor',
'created_from' => 'Created from',
'created_to' => 'Created until',
'company' => 'Company',
'consultant_status' => 'Consultant status',
'level' => 'Career level',
'include_admins' => 'Include admin accounts',
'all' => 'all',
'none_member' => 'without assignment',
'consultant_section' => 'Consultant filters',
'refresh' => 'Refresh preview',
'reset' => 'Reset filters',
],
'source_option' => [
'customers' => 'Customers',
'consultants' => 'Consultants',
'both' => 'Customers and consultants',
],
'source' => [
'customer' => 'Customer',
'consultant' => 'Consultant',
],
'optin_option' => [
'all' => 'all (opt-in is exported as a column)',
'subscribed' => 'only with opt-in',
'unsubscribed' => 'only without opt-in',
],
'optin_value' => [
'yes' => 'yes',
'no' => 'no',
'contract' => 'contract',
],
'purchase_option' => [
'all' => 'all',
'has_buyed' => 'has purchased',
'none' => 'never purchased',
'months_3' => 'purchased in the last 3 months',
'months_6' => 'purchased in the last 6 months',
'months_12' => 'purchased in the last 12 months',
'active_abo' => 'has an active subscription',
],
'company_option' => [
'all' => 'all',
'with' => 'only with company',
'without' => 'only without company',
],
'consultant_status_option' => [
'active' => 'active only',
'inactive' => 'inactive only',
'all' => 'all',
],
'status_value' => [
'active' => 'active',
'inactive' => 'inactive',
],
'stats' => [
'total' => 'unique addresses',
'customers' => 'customers',
'consultants' => 'consultants',
'duplicates' => 'duplicates removed',
'invalid' => 'invalid addresses',
'loading' => 'Calculating list …',
],
'hint' => [
'optin_all' => 'The list also contains customers without newsletter opt-in. The "Newsletter" column marks the status per address.',
'consultants_contract' => 'Consultants have no separate opt-in; the legal basis is the consultant contract ("Newsletter" column = contract).',
],
'export' => [
'email' => 'Email address',
'firstname' => 'First name',
'lastname' => 'Last name',
'salutation_label' => 'Salutation',
'street' => 'Street',
'zipcode' => 'Postcode',
'city' => 'City',
'company' => 'Company',
'consultant' => 'Consultant',
'country' => 'Country',
'language' => 'Language',
'source_label' => 'Source',
'number' => 'Number',
'consultant_number' => 'Consultant no.',
'optin_label' => 'Newsletter',
'status_label' => 'Status',
'created_at' => 'Created',
'button_xlsx' => 'Export XLSX',
'button_csv' => 'Export CSV',
'template_only' => 'Export only the 9 template columns',
],
'table' => [
'email' => 'E-mail',
'name' => 'Name',
'salutation' => 'Salutation',
'zipcode' => 'Postcode',
'city' => 'City',
'country' => 'Country',
'language' => 'Language',
'consultant' => 'Consultant / sponsor',
'source' => 'Source',
'optin' => 'Newsletter',
'status' => 'Status',
'created' => 'Created',
],
'saved' => [
'title' => 'Saved lists',
'current' => 'Loaded list',
'name' => 'Name',
'name_placeholder' => 'e.g. Newsletter DE customers',
'save_new' => 'Save as new list',
'save_changes' => 'Save changes to this list',
'summary' => 'Filters',
'source' => 'Source',
'last_export' => 'Last export',
'last_count' => 'Addresses',
'created_by' => 'Created by',
'created_at' => 'Created',
'actions' => 'Actions',
'load' => 'Load filters',
'delete' => 'Delete',
'confirm_delete' => 'Really delete this list? The export log is kept.',
'stored' => 'List saved.',
'updated' => 'List updated.',
'deleted' => 'List deleted.',
'intro' => 'Saved filter combinations. "Load filters" puts the list into the preview; the export uses the stored filters.',
],
'exports' => [
'title' => 'Export log',
'intro' => 'Every download is recorded with user, time, filters and number of addresses.',
'date' => 'Date',
'user' => 'User',
'list' => 'List',
'adhoc' => 'without saved list',
'summary' => 'Filters',
'format' => 'Format',
'rows' => 'Addresses',
'filename' => 'File',
],
'validation' => [
'source' => 'Invalid source.',
'option' => 'Invalid filter value.',
'member' => 'Invalid consultant selection.',
'date' => 'Invalid date.',
'name' => 'Please enter a name (max. 120 characters).',
'list' => 'List not found.',
'date_range' => 'The end date must not be before the start date.',
],
];

View file

@ -95,4 +95,8 @@ return [
'my_statistics' => 'Mi estadística',
'team_statistics' => 'Estadística del equipo',
'withdrawals' => 'Desistimientos',
'newsletter_lists' => 'Listas de newsletter',
'newsletter_create' => 'Crear lista',
'newsletter_saved' => 'Listas guardadas',
'newsletter_exports' => 'Registro de exportaciones',
];

View file

@ -0,0 +1,158 @@
<?php
return [
'title' => 'Listas de newsletter',
'intro' => 'Crea una lista de destinatarios a partir de clientes y tutores, revísala y expórtala para rapidmail. Los registros eliminados, anonimizados, falsos y de prueba se excluyen siempre; las direcciones duplicadas se eliminan automáticamente.',
'filter' => [
'source' => 'Fuente',
'language' => 'Idioma',
'country' => 'País',
'optin' => 'Opt-in newsletter',
'purchase' => 'Estado de compra',
'member' => 'Tutor / patrocinador asignado',
'created_from' => 'Creado desde',
'created_to' => 'Creado hasta',
'company' => 'Empresa',
'consultant_status' => 'Estado del tutor',
'level' => 'Nivel de carrera',
'include_admins' => 'Incluir cuentas de administrador',
'all' => 'todos',
'none_member' => 'sin asignación',
'consultant_section' => 'Filtros de tutores',
'refresh' => 'Actualizar vista previa',
'reset' => 'Restablecer filtros',
],
'source_option' => [
'customers' => 'Clientes',
'consultants' => 'Tutores',
'both' => 'Clientes y tutores',
],
'source' => [
'customer' => 'Cliente',
'consultant' => 'Tutor',
],
'optin_option' => [
'all' => 'todos (el opt-in se exporta como columna)',
'subscribed' => 'solo con opt-in',
'unsubscribed' => 'solo sin opt-in',
],
'optin_value' => [
'yes' => 'sí',
'no' => 'no',
'contract' => 'contrato',
],
'purchase_option' => [
'all' => 'todos',
'has_buyed' => 'ha comprado',
'none' => 'nunca ha comprado',
'months_3' => 'compró en los últimos 3 meses',
'months_6' => 'compró en los últimos 6 meses',
'months_12' => 'compró en los últimos 12 meses',
'active_abo' => 'tiene una suscripción activa',
],
'company_option' => [
'all' => 'todos',
'with' => 'solo con empresa',
'without' => 'solo sin empresa',
],
'consultant_status_option' => [
'active' => 'solo activos',
'inactive' => 'solo inactivos',
'all' => 'todos',
],
'status_value' => [
'active' => 'activo',
'inactive' => 'inactivo',
],
'stats' => [
'total' => 'direcciones únicas',
'customers' => 'clientes',
'consultants' => 'tutores',
'duplicates' => 'duplicados eliminados',
'invalid' => 'direcciones no válidas',
'loading' => 'Calculando la lista …',
],
'hint' => [
'optin_all' => 'La lista también contiene clientes sin opt-in de newsletter. La columna «Newsletter» indica el estado de cada dirección.',
'consultants_contract' => 'Los tutores no tienen opt-in propio; la base es el contrato de tutor (columna «Newsletter» = contrato).',
],
'export' => [
'email' => 'Correo electrónico',
'firstname' => 'Nombre',
'lastname' => 'Apellidos',
'salutation_label' => 'Tratamiento',
'street' => 'Calle',
'zipcode' => 'Código postal',
'city' => 'Ciudad',
'company' => 'Empresa',
'consultant' => 'Tutor',
'country' => 'País',
'language' => 'Idioma',
'source_label' => 'Fuente',
'number' => 'Número',
'consultant_number' => 'N.º de tutor',
'optin_label' => 'Newsletter',
'status_label' => 'Estado',
'created_at' => 'Creado',
'button_xlsx' => 'Exportar XLSX',
'button_csv' => 'Exportar CSV',
'template_only' => 'Exportar solo las 9 columnas de la plantilla',
],
'table' => [
'email' => 'Correo',
'name' => 'Nombre',
'salutation' => 'Tratamiento',
'zipcode' => 'CP',
'city' => 'Ciudad',
'country' => 'País',
'language' => 'Idioma',
'consultant' => 'Tutor / patrocinador',
'source' => 'Fuente',
'optin' => 'Newsletter',
'status' => 'Estado',
'created' => 'Creado',
],
'saved' => [
'title' => 'Listas guardadas',
'current' => 'Lista cargada',
'name' => 'Nombre',
'name_placeholder' => 'p. ej. Newsletter DE clientes',
'save_new' => 'Guardar como lista nueva',
'save_changes' => 'Guardar cambios en esta lista',
'summary' => 'Filtros',
'source' => 'Fuente',
'last_export' => 'Última exportación',
'last_count' => 'Direcciones',
'created_by' => 'Creado por',
'created_at' => 'Creado',
'actions' => 'Acciones',
'load' => 'Cargar filtros',
'delete' => 'Eliminar',
'confirm_delete' => '¿Eliminar esta lista? El registro de exportaciones se conserva.',
'stored' => 'Lista guardada.',
'updated' => 'Lista actualizada.',
'deleted' => 'Lista eliminada.',
'intro' => 'Combinaciones de filtros guardadas. «Cargar filtros» lleva la lista a la vista previa; la exportación usa los filtros guardados.',
],
'exports' => [
'title' => 'Registro de exportaciones',
'intro' => 'Cada descarga se registra con usuario, fecha, filtros y número de direcciones.',
'date' => 'Fecha',
'user' => 'Usuario',
'list' => 'Lista',
'adhoc' => 'sin lista guardada',
'summary' => 'Filtros',
'format' => 'Formato',
'rows' => 'Direcciones',
'filename' => 'Archivo',
],
'validation' => [
'source' => 'Fuente no válida.',
'option' => 'Valor de filtro no válido.',
'member' => 'Selección de tutor no válida.',
'date' => 'Fecha no válida.',
'name' => 'Indique un nombre (máx. 120 caracteres).',
'list' => 'Lista no encontrada.',
'date_range' => 'La fecha final no puede ser anterior a la inicial.',
],
];

View file

@ -95,4 +95,8 @@ return [
'my_statistics' => 'Ma statistique',
'team_statistics' => 'Statistiques de l\'équipe',
'withdrawals' => 'Rétractations',
'newsletter_lists' => 'Listes newsletter',
'newsletter_create' => 'Créer une liste',
'newsletter_saved' => 'Listes enregistrées',
'newsletter_exports' => 'Journal des exports',
];

View file

@ -0,0 +1,158 @@
<?php
return [
'title' => 'Listes newsletter',
'intro' => 'Composez une liste de destinataires à partir des clients et des conseillers, vérifiez-la et exportez-la pour rapidmail. Les enregistrements supprimés, anonymisés, fictifs et de test sont toujours exclus ; les adresses en double sont retirées automatiquement.',
'filter' => [
'source' => 'Source',
'language' => 'Langue',
'country' => 'Pays',
'optin' => 'Opt-in newsletter',
'purchase' => 'Statut d’achat',
'member' => 'Conseiller / parrain attribué',
'created_from' => 'Créé à partir du',
'created_to' => 'Créé jusqu’au',
'company' => 'Société',
'consultant_status' => 'Statut du conseiller',
'level' => 'Niveau de carrière',
'include_admins' => 'Inclure les comptes administrateurs',
'all' => 'tous',
'none_member' => 'sans attribution',
'consultant_section' => 'Filtres conseillers',
'refresh' => 'Actualiser l’aperçu',
'reset' => 'Réinitialiser les filtres',
],
'source_option' => [
'customers' => 'Clients',
'consultants' => 'Conseillers',
'both' => 'Clients et conseillers',
],
'source' => [
'customer' => 'Client',
'consultant' => 'Conseiller',
],
'optin_option' => [
'all' => 'tous (l’opt-in est exporté comme colonne)',
'subscribed' => 'uniquement avec opt-in',
'unsubscribed' => 'uniquement sans opt-in',
],
'optin_value' => [
'yes' => 'oui',
'no' => 'non',
'contract' => 'contrat',
],
'purchase_option' => [
'all' => 'tous',
'has_buyed' => 'a acheté',
'none' => 'n’a jamais acheté',
'months_3' => 'a acheté au cours des 3 derniers mois',
'months_6' => 'a acheté au cours des 6 derniers mois',
'months_12' => 'a acheté au cours des 12 derniers mois',
'active_abo' => 'a un abonnement actif',
],
'company_option' => [
'all' => 'tous',
'with' => 'uniquement avec société',
'without' => 'uniquement sans société',
],
'consultant_status_option' => [
'active' => 'actifs uniquement',
'inactive' => 'inactifs uniquement',
'all' => 'tous',
],
'status_value' => [
'active' => 'actif',
'inactive' => 'inactif',
],
'stats' => [
'total' => 'adresses uniques',
'customers' => 'clients',
'consultants' => 'conseillers',
'duplicates' => 'doublons retirés',
'invalid' => 'adresses invalides',
'loading' => 'Calcul de la liste …',
],
'hint' => [
'optin_all' => 'La liste contient aussi des clients sans opt-in newsletter. La colonne « Newsletter » indique le statut de chaque adresse.',
'consultants_contract' => 'Les conseillers n’ont pas d’opt-in propre ; la base est le contrat de conseiller (colonne « Newsletter » = contrat).',
],
'export' => [
'email' => 'Adresse e-mail',
'firstname' => 'Prénom',
'lastname' => 'Nom',
'salutation_label' => 'Civilité',
'street' => 'Rue',
'zipcode' => 'Code postal',
'city' => 'Ville',
'company' => 'Société',
'consultant' => 'Conseiller',
'country' => 'Pays',
'language' => 'Langue',
'source_label' => 'Source',
'number' => 'Numéro',
'consultant_number' => 'N° conseiller',
'optin_label' => 'Newsletter',
'status_label' => 'Statut',
'created_at' => 'Créé le',
'button_xlsx' => 'Exporter XLSX',
'button_csv' => 'Exporter CSV',
'template_only' => 'Exporter uniquement les 9 colonnes du modèle',
],
'table' => [
'email' => 'E-mail',
'name' => 'Nom',
'salutation' => 'Civilité',
'zipcode' => 'CP',
'city' => 'Ville',
'country' => 'Pays',
'language' => 'Langue',
'consultant' => 'Conseiller / parrain',
'source' => 'Source',
'optin' => 'Newsletter',
'status' => 'Statut',
'created' => 'Créé le',
],
'saved' => [
'title' => 'Listes enregistrées',
'current' => 'Liste chargée',
'name' => 'Nom',
'name_placeholder' => 'p. ex. Newsletter DE clients',
'save_new' => 'Enregistrer comme nouvelle liste',
'save_changes' => 'Enregistrer les modifications dans cette liste',
'summary' => 'Filtres',
'source' => 'Source',
'last_export' => 'Dernier export',
'last_count' => 'Adresses',
'created_by' => 'Créé par',
'created_at' => 'Créé le',
'actions' => 'Actions',
'load' => 'Charger les filtres',
'delete' => 'Supprimer',
'confirm_delete' => 'Supprimer vraiment cette liste ? Le journal des exports est conservé.',
'stored' => 'Liste enregistrée.',
'updated' => 'Liste mise à jour.',
'deleted' => 'Liste supprimée.',
'intro' => 'Combinaisons de filtres enregistrées. « Charger les filtres » reprend la liste dans l’aperçu ; l’export utilise les filtres enregistrés.',
],
'exports' => [
'title' => 'Journal des exports',
'intro' => 'Chaque téléchargement est consigné avec l’utilisateur, l’heure, les filtres et le nombre d’adresses.',
'date' => 'Date',
'user' => 'Utilisateur',
'list' => 'Liste',
'adhoc' => 'sans liste enregistrée',
'summary' => 'Filtres',
'format' => 'Format',
'rows' => 'Adresses',
'filename' => 'Fichier',
],
'validation' => [
'source' => 'Source invalide.',
'option' => 'Valeur de filtre invalide.',
'member' => 'Sélection de conseiller invalide.',
'date' => 'Date invalide.',
'name' => 'Veuillez saisir un nom (120 caractères max.).',
'list' => 'Liste introuvable.',
'date_range' => 'La date de fin ne peut pas précéder la date de début.',
],
];

View file

@ -0,0 +1,51 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('newsletter.exports.title') }}
</h4>
<p class="text-muted">{{ __('newsletter.exports.intro') }}</p>
<div class="card">
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatables-newsletter-exports">
<thead>
<tr>
<th>{{ __('newsletter.exports.date') }}</th>
<th>{{ __('newsletter.exports.user') }}</th>
<th>{{ __('newsletter.exports.list') }}</th>
<th>{{ __('newsletter.exports.summary') }}</th>
<th>{{ __('newsletter.exports.format') }}</th>
<th>{{ __('newsletter.exports.rows') }}</th>
<th>{{ __('newsletter.exports.filename') }}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script>
$(document).ready(function () {
$('#datatables-newsletter-exports').DataTable({
processing: true,
serverSide: true,
ajax: { url: '{!! route('admin_newsletter_lists_exports_datatable') !!}' },
order: [[0, 'desc']],
columns: [
{ data: 'created_at', name: 'created_at', searchable: false },
{ data: 'user', name: 'user', orderable: false, searchable: false },
{ data: 'list', name: 'list', orderable: false, searchable: false },
{ data: 'summary', name: 'summary', orderable: false, searchable: false },
{ data: 'format', name: 'format' },
{ data: 'row_count', name: 'row_count', searchable: false },
{ data: 'filename', name: 'filename' }
],
bLengthChange: false,
iDisplayLength: 50,
language: { url: '/js/datatables-{{ \App::getLocale() }}.json' }
});
});
</script>
@endsection

View file

@ -0,0 +1,304 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('newsletter.title') }}
</h4>
<p class="text-muted">{{ __('newsletter.intro') }}</p>
@if($list)
<div class="alert alert-secondary py-2 d-flex align-items-center justify-content-between flex-wrap">
<div>
<strong>{{ __('newsletter.saved.current') }}:</strong> {{ $list->name }}
<span class="text-muted small ml-2">
@if($list->last_exported_at)
{{ __('newsletter.saved.last_export') }}: {{ $list->last_exported_at->format('d.m.Y H:i') }} ({{ $list->last_row_count }} {{ __('newsletter.saved.last_count') }})
@endif
</span>
</div>
<a href="{{ route('admin_newsletter_lists_saved') }}" class="btn btn-sm btn-outline-secondary">{{ __('newsletter.saved.title') }}</a>
</div>
@endif
<div class="card mb-4">
<div class="card-header">
<form id="newsletter-filter" method="get" action="{{ route('admin_newsletter_lists') }}" onsubmit="return false;">
<input type="hidden" name="refresh" id="filter_refresh" value="0">
<input type="hidden" name="list_id" value="{{ $list?->id }}">
<div class="form-row">
<div class="col-md-12 mb-3">
<label class="form-label d-block">{{ __('newsletter.filter.source') }}</label>
@foreach($sources as $source)
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" class="custom-control-input" id="source_{{ $source }}" name="source" value="{{ $source }}" @if($filter['source'] === $source) checked @endif>
<label class="custom-control-label" for="source_{{ $source }}">{{ __('newsletter.source_option.'.$source) }}</label>
</div>
@endforeach
</div>
</div>
<div class="form-row">
<div class="col-md-2 mb-3">
<label class="form-label" for="filter_language">{{ __('newsletter.filter.language') }}</label>
<select class="custom-select" name="language" id="filter_language">
<option value="" @if($filter['language'] === '') selected @endif>{{ __('newsletter.filter.all') }}</option>
@foreach($languages as $language)
<option value="{{ $language }}" @if($filter['language'] === $language) selected @endif>{{ strtoupper($language) }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="filter_country_ids">{{ __('newsletter.filter.country') }}</label>
<select class="selectpicker form-control" data-style="btn-default" data-live-search="true" data-actions-box="true" multiple name="country_ids[]" id="filter_country_ids" title="{{ __('newsletter.filter.all') }}">
@foreach($countries as $country)
<option value="{{ $country->id }}" @if(in_array($country->id, $filter['country_ids'], true)) selected @endif>{{ $country->code }} – {{ $country->getLocated() }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="filter_optin">{{ __('newsletter.filter.optin') }}</label>
<select class="custom-select" name="optin" id="filter_optin">
@foreach($optin_options as $option)
<option value="{{ $option }}" @if($filter['optin'] === $option) selected @endif>{{ __('newsletter.optin_option.'.$option) }}</option>
@endforeach
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="filter_purchase">{{ __('newsletter.filter.purchase') }}</label>
<select class="custom-select" name="purchase" id="filter_purchase">
@foreach($purchase_options as $option)
<option value="{{ $option }}" @if($filter['purchase'] === $option) selected @endif>{{ __('newsletter.purchase_option.'.$option) }}</option>
@endforeach
</select>
</div>
</div>
<div class="form-row">
<div class="col-md-4 mb-3">
<label class="form-label" for="filter_member_id">{{ __('newsletter.filter.member') }}</label>
<select class="selectpicker form-control" data-style="btn-default" data-live-search="true" name="member_id" id="filter_member_id">
<option value="" @if($filter['member_id'] === '') selected @endif>{{ __('newsletter.filter.all') }}</option>
<option value="none" @if($filter['member_id'] === 'none') selected @endif>{{ __('newsletter.filter.none_member') }}</option>
@foreach($members as $member)
<option value="{{ $member->id }}" @if((string) $filter['member_id'] === (string) $member->id) selected @endif>{{ $member->getFullName() }}</option>
@endforeach
</select>
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="filter_created_from">{{ __('newsletter.filter.created_from') }}</label>
<input type="date" class="form-control" name="created_from" id="filter_created_from" value="{{ $filter['created_from'] }}">
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="filter_created_to">{{ __('newsletter.filter.created_to') }}</label>
<input type="date" class="form-control" name="created_to" id="filter_created_to" value="{{ $filter['created_to'] }}">
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="filter_company">{{ __('newsletter.filter.company') }}</label>
<select class="custom-select" name="company" id="filter_company">
@foreach($company_options as $option)
<option value="{{ $option }}" @if($filter['company'] === $option) selected @endif>{{ __('newsletter.company_option.'.$option) }}</option>
@endforeach
</select>
</div>
</div>
<div id="consultant-filters" class="border-top pt-3 mt-1" @if($filter['source'] === 'customers') style="display:none" @endif>
<div class="small font-weight-semibold text-muted mb-2">{{ __('newsletter.filter.consultant_section') }}</div>
<div class="form-row">
<div class="col-md-3 mb-3">
<label class="form-label" for="filter_consultant_status">{{ __('newsletter.filter.consultant_status') }}</label>
<select class="custom-select" name="consultant_status" id="filter_consultant_status">
@foreach($consultant_status_options as $option)
<option value="{{ $option }}" @if($filter['consultant_status'] === $option) selected @endif>{{ __('newsletter.consultant_status_option.'.$option) }}</option>
@endforeach
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="filter_level_ids">{{ __('newsletter.filter.level') }}</label>
<select class="selectpicker form-control" data-style="btn-default" data-actions-box="true" multiple name="level_ids[]" id="filter_level_ids" title="{{ __('newsletter.filter.all') }}">
@foreach($levels as $level)
<option value="{{ $level->id }}" @if(in_array($level->id, $filter['level_ids'], true)) selected @endif>{{ $level->name }}</option>
@endforeach
</select>
</div>
<div class="col-md-3 mb-3 pt-4">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" name="include_admins" id="filter_include_admins" value="1" @if($filter['include_admins']) checked @endif>
<label class="custom-control-label" for="filter_include_admins">{{ __('newsletter.filter.include_admins') }}</label>
</div>
</div>
</div>
</div>
<div class="form-row align-items-center border-top pt-3">
<div class="col-md-8 mb-2">
<button type="button" class="btn btn-primary" id="filter_apply"><span class="fa fa-sync"></span> {{ __('newsletter.filter.refresh') }}</button>
<a href="{{ route('admin_newsletter_lists') }}?reset=filter" class="btn btn-outline-dark">{{ __('newsletter.filter.reset') }}</a>
</div>
<div class="col-md-4 mb-2 text-right">
<div class="custom-control custom-checkbox d-inline-block mr-3">
<input type="checkbox" class="custom-control-input" name="template_only" id="filter_template_only" value="1" @if($filter['template_only']) checked @endif>
<label class="custom-control-label" for="filter_template_only">{{ __('newsletter.export.template_only') }}</label>
</div>
<a href="#" class="btn btn-success export-link" data-format="xlsx"><span class="fa fa-file-excel"></span> {{ __('newsletter.export.button_xlsx') }}</a>
<a href="#" class="btn btn-outline-success export-link" data-format="csv"><span class="fa fa-file-csv"></span> {{ __('newsletter.export.button_csv') }}</a>
</div>
</div>
</form>
<form id="newsletter-save-form" method="post" action="{{ route('admin_newsletter_lists_store') }}" class="form-row align-items-center border-top pt-3 mt-2">
@csrf
<div id="newsletter-save-fields"></div>
<div class="col-md-5 mb-2">
<input type="text" class="form-control" name="name" maxlength="120" required placeholder="{{ __('newsletter.saved.name_placeholder') }}" value="{{ old('name', $list?->name) }}">
@error('name')<div class="text-danger small">{{ $message }}</div>@enderror
</div>
<div class="col-md-7 mb-2">
@if($list)
<button type="submit" class="btn btn-outline-primary" data-action="{{ route('admin_newsletter_lists_update', [$list->id]) }}"><span class="fa fa-save"></span> {{ __('newsletter.saved.save_changes') }}</button>
@endif
<button type="submit" class="btn btn-outline-primary" data-action="{{ route('admin_newsletter_lists_store') }}"><span class="fa fa-plus"></span> {{ __('newsletter.saved.save_new') }}</button>
</div>
</form>
</div>
<div class="card-body py-2">
<div class="alert alert-warning py-2 mb-2" id="hint_optin_all" @if($filter['optin'] !== 'all') style="display:none" @endif>{{ __('newsletter.hint.optin_all') }}</div>
<div class="alert alert-info py-2 mb-2" id="hint_consultants" @if($filter['source'] === 'customers') style="display:none" @endif>{{ __('newsletter.hint.consultants_contract') }}</div>
<div id="newsletter-stats" class="text-muted small">{{ __('newsletter.stats.loading') }}</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatables-newsletter">
<thead>
<tr>
<th>{{ __('newsletter.table.email') }}</th>
<th>{{ __('newsletter.table.name') }}</th>
<th>{{ __('newsletter.table.salutation') }}</th>
<th>{{ __('newsletter.table.zipcode') }}</th>
<th>{{ __('newsletter.table.city') }}</th>
<th>{{ __('newsletter.table.country') }}</th>
<th>{{ __('newsletter.table.language') }}</th>
<th>{{ __('newsletter.table.consultant') }}</th>
<th>{{ __('newsletter.table.source') }}</th>
<th>{{ __('newsletter.table.optin') }}</th>
<th>{{ __('newsletter.table.status') }}</th>
<th>{{ __('newsletter.table.created') }}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script>
$(document).ready(function () {
var form = $('#newsletter-filter');
var statsLabels = {
total: @json(__('newsletter.stats.total')),
customers: @json(__('newsletter.stats.customers')),
consultants: @json(__('newsletter.stats.consultants')),
duplicates: @json(__('newsletter.stats.duplicates')),
invalid: @json(__('newsletter.stats.invalid'))
};
function filterParams(d) {
d = d || {};
form.serializeArray().forEach(function (field) {
if (field.name.slice(-2) === '[]') {
var key = field.name.slice(0, -2);
(d[key] = d[key] || []).push(field.value);
} else {
d[field.name] = field.value;
}
});
return d;
}
var oTable = $('#datatables-newsletter').DataTable({
processing: true,
serverSide: true,
ajax: {
url: '{!! route('admin_newsletter_lists_datatable') !!}',
data: function (d) {
filterParams(d);
}
},
order: [[0, 'asc']],
columns: [
{ data: 'email', name: 'email' },
{ data: 'name', name: 'name', orderable: false },
{ data: 'salutation_label', name: 'salutation_label' },
{ data: 'zipcode', name: 'zipcode' },
{ data: 'city', name: 'city' },
{ data: 'country', name: 'country' },
{ data: 'language', name: 'language' },
{ data: 'consultant', name: 'consultant' },
{ data: 'source_label', name: 'source_label' },
{ data: 'optin_label', name: 'optin_label' },
{ data: 'status_label', name: 'status_label' },
{ data: 'created_at', name: 'created_at' }
],
bLengthChange: false,
iDisplayLength: 50,
language: {
url: '/js/datatables-{{ \App::getLocale() }}.json'
}
});
$('#datatables-newsletter').on('xhr.dt', function (e, settings, json) {
$('#filter_refresh').val('0');
if (!json || !json.stats) {
return;
}
var s = json.stats;
$('#newsletter-stats').html(
'<strong>' + s.total + '</strong> ' + statsLabels.total +
' &middot; ' + statsLabels.customers + ': ' + s.customers +
' &middot; ' + statsLabels.consultants + ': ' + s.consultants +
' &middot; ' + statsLabels.duplicates + ': ' + s.duplicates +
' &middot; ' + statsLabels.invalid + ': ' + s.invalid
);
});
function syncHints() {
var source = form.find('input[name=source]:checked').val();
$('#consultant-filters').toggle(source !== 'customers');
$('#hint_consultants').toggle(source !== 'customers');
$('#hint_optin_all').toggle($('#filter_optin').val() === 'all' && source !== 'consultants');
}
$('#filter_apply').on('click', function () {
$('#filter_refresh').val('1');
syncHints();
oTable.draw();
});
form.find('select, input[type=date], input[type=checkbox], input[type=radio]').on('change', function () {
syncHints();
oTable.draw();
});
$('#newsletter-save-form button[type=submit]').on('click', function () {
$('#newsletter-save-form').attr('action', $(this).data('action'));
});
$('#newsletter-save-form').on('submit', function () {
var fields = $('#newsletter-save-fields').empty();
form.serializeArray().forEach(function (field) {
if (field.name === 'refresh' || field.name === 'list_id') {
return;
}
fields.append($('<input>', { type: 'hidden', name: field.name, value: field.value }));
});
});
$('.export-link').on('click', function (e) {
e.preventDefault();
var base = '{!! route('admin_newsletter_lists_export', ['format' => '__FORMAT__']) !!}'.replace('__FORMAT__', $(this).data('format'));
window.location.href = base + '?' + $.param(filterParams({}));
});
});
</script>
@endsection

View file

@ -0,0 +1,56 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('newsletter.saved.title') }}
</h4>
<p class="text-muted">{{ __('newsletter.saved.intro') }}</p>
<div class="card">
<div class="card-header">
<a href="{{ route('admin_newsletter_lists') }}" class="btn btn-sm btn-primary"><span class="fa fa-plus"></span> {{ __('navigation.newsletter_create') }}</a>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped table-bordered" id="datatables-newsletter-saved">
<thead>
<tr>
<th>{{ __('newsletter.saved.name') }}</th>
<th>{{ __('newsletter.saved.source') }}</th>
<th>{{ __('newsletter.saved.summary') }}</th>
<th>{{ __('newsletter.saved.last_export') }}</th>
<th>{{ __('newsletter.saved.last_count') }}</th>
<th>{{ __('newsletter.saved.created_by') }}</th>
<th>{{ __('newsletter.saved.created_at') }}</th>
<th>{{ __('newsletter.saved.actions') }}</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script>
$(document).ready(function () {
$('#datatables-newsletter-saved').DataTable({
processing: true,
serverSide: true,
ajax: { url: '{!! route('admin_newsletter_lists_saved_datatable') !!}' },
order: [[0, 'asc']],
columns: [
{ data: 'name', name: 'name' },
{ data: 'source', name: 'source' },
{ data: 'summary', name: 'summary', orderable: false, searchable: false },
{ data: 'last_exported_at', name: 'last_exported_at', searchable: false },
{ data: 'last_row_count', name: 'last_row_count', searchable: false },
{ data: 'creator', name: 'creator', orderable: false, searchable: false },
{ data: 'created_at', name: 'created_at', searchable: false },
{ data: 'actions', name: 'actions', orderable: false, searchable: false }
],
bLengthChange: false,
iDisplayLength: 50,
language: { url: '/js/datatables-{{ \App::getLocale() }}.json' }
});
});
</script>
@endsection

View file

@ -326,6 +326,32 @@
<div>{{ __('navigation.clients') }}</div>
</a>
</li>
<li class="sidenav-item @if (Request::is('admin/newsletter-lists*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-mail"></i>
<div>{{ __('navigation.newsletter_lists') }}</div>
</a>
<ul class="sidenav-menu">
<li class="sidenav-item{{ Request::is('admin/newsletter-lists', 'admin/newsletter-lists/load/*') ? ' active' : '' }}">
<a href="{{ route('admin_newsletter_lists') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-funnel"></i>
<div>{{ __('navigation.newsletter_create') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/newsletter-lists/saved*') ? ' active' : '' }}">
<a href="{{ route('admin_newsletter_lists_saved') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-bookmarks"></i>
<div>{{ __('navigation.newsletter_saved') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/newsletter-lists/exports*') ? ' active' : '' }}">
<a href="{{ route('admin_newsletter_lists_exports') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-list-box"></i>
<div>{{ __('navigation.newsletter_exports') }}</div>
</a>
</li>
</ul>
</li>
<li class="sidenav-item @if (Request::is('admin/sales/*', 'admin/abos', 'admin/abos/*', 'admin/withdrawals', 'admin/withdrawals/*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-ios-cart"></i>

View file

@ -343,6 +343,20 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/admin/customer/edit/{id}', 'CustomerController@store')->name('admin_customer_edit');
Route::get('/admin/customer/datatable', 'CustomerController@getCustomers')->name('admin_customer_datatable');
// newsletter lists
Route::get('/admin/newsletter-lists', 'Admin\NewsletterListController@index')->name('admin_newsletter_lists');
Route::get('/admin/newsletter-lists/datatable', 'Admin\NewsletterListController@datatable')->name('admin_newsletter_lists_datatable');
Route::get('/admin/newsletter-lists/export/{format}', 'Admin\NewsletterListController@export')->name('admin_newsletter_lists_export')->where('format', 'xlsx|csv');
Route::get('/admin/newsletter-lists/saved', 'Admin\NewsletterListController@saved')->name('admin_newsletter_lists_saved');
Route::get('/admin/newsletter-lists/saved/datatable', 'Admin\NewsletterListController@savedDatatable')->name('admin_newsletter_lists_saved_datatable');
Route::get('/admin/newsletter-lists/saved/{list}/export/{format}', 'Admin\NewsletterListController@exportSaved')->name('admin_newsletter_lists_saved_export')->where('format', 'xlsx|csv');
Route::get('/admin/newsletter-lists/load/{list}', 'Admin\NewsletterListController@load')->name('admin_newsletter_lists_load');
Route::post('/admin/newsletter-lists/save', 'Admin\NewsletterListController@store')->name('admin_newsletter_lists_store');
Route::post('/admin/newsletter-lists/update/{list}', 'Admin\NewsletterListController@update')->name('admin_newsletter_lists_update');
Route::post('/admin/newsletter-lists/delete/{list}', 'Admin\NewsletterListController@destroy')->name('admin_newsletter_lists_destroy');
Route::get('/admin/newsletter-lists/exports', 'Admin\NewsletterListController@exports')->name('admin_newsletter_lists_exports');
Route::get('/admin/newsletter-lists/exports/datatable', 'Admin\NewsletterListController@exportsDatatable')->name('admin_newsletter_lists_exports_datatable');
Route::get('/admin/lead/change_mail/{id}', 'UserUpdateEmailController@adminChangeMail')->name('admin_lead_change_mail');
Route::post('/admin/lead/change_mail/{id}', 'UserUpdateEmailController@adminUpdateMail')->name('admin_lead_change_mail');
Route::get('/admin/lead/new_mail/verified/{id}', 'LeadController@newMailVerified')->name('admin_lead_new_mail_verified');

View file

@ -0,0 +1,153 @@
<?php
use App\Exports\xExport;
use App\Http\Controllers\Admin\NewsletterListController;
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
use App\Models\ShoppingUser;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\ValidationException;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function newsletterAdmin(int $admin = 2): User
{
return User::forceCreate([
'email' => 'admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'admin' => $admin,
'lang' => 'de',
'active' => 1,
'blocked' => 0,
]);
}
/**
* @param array<string, mixed> $params
*/
function newsletterRequest(User $user, array $params = []): NewsletterListFilterRequest
{
$request = NewsletterListFilterRequest::create('/admin/newsletter-lists/datatable', 'GET', $params);
$request->setUserResolver(fn () => $user);
$request->setContainer(app())->setRedirector(app(Redirector::class));
$request->validateResolved();
app()->instance('request', $request);
return $request;
}
function newsletterController(): NewsletterListController
{
return new NewsletterListController(app(NewsletterListService::class));
}
/**
* @param array<string, mixed> $attributes
*/
function newsletterListCustomer(array $attributes = []): ShoppingUser
{
return ShoppingUser::forceCreate(array_merge([
'auth_user_id' => 0,
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_salutation' => 'ms',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
], $attributes));
}
it('verweigert Nicht-Admins den Zugriff auf die Filter-Anfrage', function () {
$this->actingAs(newsletterAdmin(0));
newsletterRequest(auth()->user(), ['source' => 'customers']);
})->throws(AuthorizationException::class);
it('lehnt ungültige Filterwerte ab', function () {
$this->actingAs(newsletterAdmin());
newsletterRequest(auth()->user(), ['source' => 'alle', 'created_from' => '2026-02-01', 'created_to' => '2026-01-01']);
})->throws(ValidationException::class);
it('liefert die Vorschau als Datatable-JSON mit Kennzahlen', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'a@example.com']);
newsletterListCustomer(['billing_email' => 'A@example.com']);
newsletterListCustomer(['billing_email' => 'b@example.com', 'subscribed' => 0]);
$request = newsletterRequest($admin, ['source' => 'customers', 'draw' => 1, 'start' => 0, 'length' => 50]);
$json = newsletterController()->datatable($request)->getData(true);
expect($json['recordsTotal'])->toBe(2)
->and($json['stats']['total'])->toBe(2)
->and($json['stats']['duplicates'])->toBe(1)
->and(collect($json['data'])->pluck('email')->all())->toBe(['a@example.com', 'b@example.com'])
->and($json['data'][0]['name'])->toBe('Erika Muster')
->and($json['data'][0]['optin_label'])->toContain('badge-success')
->and($json['data'][1]['optin_label'])->toContain('badge-danger')
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('customers');
});
it('exportiert die Liste als CSV mit BOM, Semikolon und Injection-Schutz', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'csv@example.com', 'billing_firstname' => '=SUMME(A1)']);
$response = newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'template_only' => 1]), 'csv');
expect($response)->toBeInstanceOf(StreamedResponse::class);
ob_start();
$response->sendContent();
$content = (string) ob_get_clean();
$lines = explode("\n", trim($content));
expect($content)->toStartWith("\xEF\xBB\xBF")
->and($lines[0])->toBe("\xEF\xBB\xBF".'Emailadresse;Vorname;Nachname;Anrede;Straße;PLZ;Wohnort;Firma;Berater')
->and($lines[1])->toBe("csv@example.com;'=SUMME(A1);Muster;Frau;\"Musterweg 1\";12345;Musterstadt;;")
->and($response->headers->get('content-disposition'))->toContain('newsletter_customers_de_'.now()->format('Y-m-d').'.csv');
});
it('exportiert die Liste als XLSX über die Export-Klasse', function () {
Excel::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
newsletterListCustomer(['billing_email' => 'xlsx@example.com']);
newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'language' => '']), 'xlsx');
Excel::assertDownloaded('newsletter_customers_alle_'.now()->format('Y-m-d').'.xlsx', function (xExport $export) {
$rows = $export->collection();
return $export->headings()[0][0] === 'Emailadresse'
&& count($export->headings()[0]) === 17
&& $rows->count() === 1
&& $rows->first()[0] === 'xlsx@example.com';
});
});
it('zeigt die Index-Seite mit gemerktem Filter', function () {
$admin = newsletterAdmin();
$this->actingAs($admin);
set_user_attr('newsletter_list_filter', ['source' => 'both', 'language' => 'es', 'optin' => 'subscribed']);
$view = newsletterController()->index(newsletterRequest($admin));
expect($view->getName())->toBe('admin.newsletter.index')
->and($view->getData()['filter']['source'])->toBe('both')
->and($view->getData()['filter']['language'])->toBe('es')
->and($view->getData()['filter']['optin'])->toBe('subscribed');
});

View file

@ -0,0 +1,256 @@
<?php
use App\Http\Controllers\Admin\NewsletterListController;
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
use App\Http\Requests\Newsletter\StoreNewsletterListRequest;
use App\Models\NewsletterList;
use App\Models\NewsletterListExport;
use App\Models\ShoppingUser;
use App\Services\Newsletter\NewsletterListFilter;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;
use Maatwebsite\Excel\Facades\Excel;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Domain-Routen werden im Test-Kontext nicht geladen (Host ist nicht die CRM-Domain).
* Für die Weiterleitungen und Links des Controllers registrieren wir die CRM-Routen hier.
*/
beforeEach(function () {
if (! app('router')->has('admin_newsletter_lists_load')) {
Route::group([], base_path('routes/domains/crm.php'));
app('router')->getRoutes()->refreshNameLookups();
}
});
function newsletterSavedAdmin(): User
{
return User::forceCreate([
'email' => 'admin-saved-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'admin' => 2,
'lang' => 'de',
'active' => 1,
'blocked' => 0,
]);
}
/**
* @param array<string, mixed> $params
*/
function newsletterStoreRequest(User $user, array $params = []): StoreNewsletterListRequest
{
$request = StoreNewsletterListRequest::create('/admin/newsletter-lists/save', 'POST', $params);
$request->setUserResolver(fn () => $user);
$request->setContainer(app())->setRedirector(app(Redirector::class));
$request->validateResolved();
app()->instance('request', $request);
return $request;
}
/**
* @param array<string, mixed> $filters
*/
function newsletterSavedList(User $user, array $filters = [], string $name = 'Testliste'): NewsletterList
{
$list = new NewsletterList(['name' => $name, 'created_by' => $user->id]);
$list->applyFilter(NewsletterListFilter::fromArray($filters));
$list->save();
return $list;
}
function newsletterSavedCustomer(string $email): ShoppingUser
{
return ShoppingUser::forceCreate([
'auth_user_id' => 0,
'billing_email' => $email,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_salutation' => 'ms',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
]);
}
it('speichert die aktuelle Filterkombination unter einem Namen', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$request = newsletterStoreRequest($admin, ['name' => ' Newsletter DE Kunden ', 'source' => 'both', 'language' => 'de', 'optin' => 'subscribed', 'level_ids' => ['2']]);
$response = (new NewsletterListController(app(NewsletterListService::class)))->store($request);
$list = NewsletterList::first();
expect($list->name)->toBe('Newsletter DE Kunden')
->and($list->source)->toBe('both')
->and($list->created_by)->toBe($admin->id)
->and($list->filters['optin'])->toBe('subscribed')
->and($list->filters['level_ids'])->toBe([2])
->and($list->toFilter()->optin)->toBe('subscribed')
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/load/'.$list->id);
});
it('verlangt beim Speichern einen Namen', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterStoreRequest($admin, ['source' => 'customers']);
})->throws(ValidationException::class);
it('aktualisiert Name und Filter einer gespeicherten Liste', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'customers']);
$request = newsletterStoreRequest($admin, ['name' => 'Berater ES', 'source' => 'consultants', 'language' => 'es', 'consultant_status' => 'all']);
(new NewsletterListController(app(NewsletterListService::class)))->update($request, $list);
$list->refresh();
expect($list->name)->toBe('Berater ES')
->and($list->source)->toBe('consultants')
->and($list->filters['language'])->toBe('es')
->and($list->filters['consultant_status'])->toBe('all');
});
it('lädt eine gespeicherte Liste in die Filter-Seite und merkt den Filter', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'consultants', 'language' => '']);
$view = (new NewsletterListController(app(NewsletterListService::class)))->load($list);
expect($view->getName())->toBe('admin.newsletter.index')
->and($view->getData()['list']->id)->toBe($list->id)
->and($view->getData()['filter']['source'])->toBe('consultants')
->and($view->getData()['filter']['language'])->toBe('')
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('consultants');
});
it('protokolliert Exporte und aktualisiert die gespeicherte Liste', function () {
Excel::fake();
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterSavedCustomer('eins@example.com');
newsletterSavedCustomer('zwei@example.com');
$list = newsletterSavedList($admin, ['source' => 'customers', 'language' => 'de']);
$request = NewsletterListFilterRequest::create('/x', 'GET', []);
$request->setUserResolver(fn () => $admin);
(new NewsletterListController(app(NewsletterListService::class)))->exportSaved($request, $list, 'xlsx');
$export = NewsletterListExport::first();
$list->refresh();
expect($export->newsletter_list_id)->toBe($list->id)
->and($export->user_id)->toBe($admin->id)
->and($export->format)->toBe('xlsx')
->and($export->row_count)->toBe(2)
->and($export->filename)->toBe('newsletter_customers_de_'.now()->format('Y-m-d').'.xlsx')
->and($export->filters['source'])->toBe('customers')
->and($export->stats['total'])->toBe(2)
->and($list->last_row_count)->toBe(2)
->and($list->last_exported_at)->not->toBeNull();
Excel::assertDownloaded($export->filename);
});
it('protokolliert Ad-hoc-Exporte ohne Liste und ordnet sie bei list_id der Liste zu', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
newsletterSavedCustomer('csv@example.com');
$list = newsletterSavedList($admin);
$controller = new NewsletterListController(app(NewsletterListService::class));
$adhoc = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers']);
$adhoc->setUserResolver(fn () => $admin);
$adhoc->setContainer(app())->setRedirector(app(Redirector::class));
$adhoc->validateResolved();
$controller->export($adhoc, 'csv');
$linked = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers', 'list_id' => (string) $list->id]);
$linked->setUserResolver(fn () => $admin);
$linked->setContainer(app())->setRedirector(app(Redirector::class));
$linked->validateResolved();
$controller->export($linked, 'csv');
$exports = NewsletterListExport::orderBy('id')->get();
expect($exports)->toHaveCount(2)
->and($exports[0]->newsletter_list_id)->toBeNull()
->and($exports[0]->format)->toBe('csv')
->and($exports[1]->newsletter_list_id)->toBe($list->id)
->and($list->fresh()->last_row_count)->toBe(1);
});
it('löscht eine Liste und behält das Protokoll', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin);
NewsletterListExport::create([
'newsletter_list_id' => $list->id,
'user_id' => $admin->id,
'format' => 'csv',
'row_count' => 3,
'filename' => 'x.csv',
'filters' => $list->filters,
]);
$response = (new NewsletterListController(app(NewsletterListService::class)))->destroy($list);
expect(NewsletterList::count())->toBe(0)
->and(NewsletterListExport::first()->newsletter_list_id)->toBeNull()
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/saved');
});
it('liefert gespeicherte Listen und Protokoll als Datatable-JSON', function () {
$admin = newsletterSavedAdmin();
$this->actingAs($admin);
$list = newsletterSavedList($admin, ['source' => 'customers', 'optin' => 'subscribed', 'purchase' => 'months_6'], 'Kunden Opt-in');
NewsletterListExport::create([
'newsletter_list_id' => $list->id,
'user_id' => $admin->id,
'format' => 'xlsx',
'row_count' => 7,
'filename' => 'newsletter_customers_de.xlsx',
'filters' => $list->filters,
'stats' => ['total' => 7],
]);
$request = Request::create('/x', 'GET', ['draw' => 1, 'start' => 0, 'length' => 50]);
$request->setUserResolver(fn () => $admin);
app()->instance('request', $request);
$controller = new NewsletterListController(app(NewsletterListService::class));
$saved = $controller->savedDatatable()->getData(true);
$exports = $controller->exportsDatatable()->getData(true);
expect($saved['recordsTotal'])->toBe(1)
->and($saved['data'][0]['name'])->toContain('Kunden Opt-in')
->and($saved['data'][0]['source'])->toBe('Kunden')
->and($saved['data'][0]['summary'])->toContain('nur mit Opt-in')
->and($saved['data'][0]['summary'])->toContain('6 Monaten')
->and($saved['data'][0]['actions'])->toContain('/admin/newsletter-lists/saved/'.$list->id.'/export/xlsx')
->and($exports['recordsTotal'])->toBe(1)
->and($exports['data'][0]['format'])->toBe('XLSX')
->and($exports['data'][0]['row_count'])->toBe(7)
->and($exports['data'][0]['list'])->toContain('Kunden Opt-in');
});
it('beschreibt einen Filter lesbar', function () {
$service = app(NewsletterListService::class);
$text = $service->describe(NewsletterListFilter::fromArray([
'source' => 'both', 'language' => 'de', 'optin' => 'unsubscribed', 'member_id' => 'none',
'created_from' => '2026-01-01', 'company' => 'with', 'consultant_status' => 'all', 'include_admins' => 1,
]));
expect($text)->toBe('Kunden und Berater · Sprache: DE · Newsletter-Opt-in: nur ohne Opt-in · Zugeordneter Berater / Sponsor: ohne Zuordnung · Angelegt von: 01.01.2026 – … · Firma: nur mit Firma · Berater-Status: alle · Admin-Accounts einbeziehen');
});

View file

@ -0,0 +1,364 @@
<?php
use App\Models\Country;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserLevel;
use App\Services\Newsletter\NewsletterListFilter;
use App\Services\Newsletter\NewsletterListResult;
use App\Services\Newsletter\NewsletterListService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function newsletterCountry(string $code = 'DE'): Country
{
return Country::firstOrCreate(['code' => $code], [
'phone' => '49', 'en' => $code, 'de' => $code, 'es' => $code, 'fr' => $code, 'it' => $code, 'ru' => $code,
]);
}
/**
* @param array<string, mixed> $attributes
*/
function newsletterCustomer(array $attributes = []): ShoppingUser
{
return ShoppingUser::forceCreate(array_merge([
'auth_user_id' => 0,
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_salutation' => 'ms',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_country_id' => newsletterCountry()->id,
'shipping_country_id' => 1,
'language' => 'de',
'subscribed' => 1,
'has_buyed' => 1,
'faker_mail' => 0,
'number' => 'K-'.uniqid('', true),
], $attributes));
}
/**
* @param array<string, mixed> $userAttributes
* @param array<string, mixed> $accountAttributes
*/
function newsletterConsultant(array $userAttributes = [], array $accountAttributes = []): User
{
$account = UserAccount::forceCreate(array_merge([
'm_account' => 'B-'.uniqid('', true),
'salutation' => 'mr',
'first_name' => 'Max',
'last_name' => 'Berater',
'address' => 'Beraterstraße 2',
'zipcode' => '54321',
'city' => 'Beraterstadt',
'country_id' => newsletterCountry()->id,
], $accountAttributes));
return User::forceCreate(array_merge([
'email' => 'berater-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'account_id' => $account->id,
'active' => 1,
'blocked' => 0,
'admin' => 0,
'test_mode' => 0,
], $userAttributes))->fresh();
}
/**
* @param array<string, mixed> $input
*/
function newsletterBuild(array $input = []): NewsletterListResult
{
return app(NewsletterListService::class)->build(NewsletterListFilter::fromArray($input));
}
it('liefert Kunden mit den Vorlage-Spalten und dem zugeordneten Berater', function () {
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
$customer = newsletterCustomer(['billing_email' => 'Erika.Muster@Example.com ', 'member_id' => $consultant->id, 'billing_address_2' => 'Hinterhaus']);
$result = newsletterBuild(['source' => 'customers']);
expect($result->rows)->toHaveCount(1);
$row = $result->rows->first();
expect($row['email'])->toBe('erika.muster@example.com')
->and($row['firstname'])->toBe('Erika')
->and($row['salutation_label'])->toBe('Frau')
->and($row['street'])->toBe('Musterweg 1, Hinterhaus')
->and($row['consultant'])->toBe('Anna Sponsor')
->and($row['consultant_number'])->toBe($consultant->account->m_account)
->and($row['country'])->toBe('DE')
->and($row['source'])->toBe('customer')
->and($row['optin'])->toBe('yes')
->and($row['number'])->toBe($customer->number);
});
it('schließt gelöschte, anonymisierte, Fake-, Test- und Berater-Kundendatensätze immer aus', function () {
newsletterCustomer(['billing_email' => 'ok@example.com']);
newsletterCustomer(['billing_email' => 'faker@example.com', 'faker_mail' => 1]);
newsletterCustomer(['billing_email' => 'geloescht_5@dsgvo.invalid']);
newsletterCustomer(['billing_email' => 'x-faker@mivita.care']);
newsletterCustomer(['billing_email' => 'dev@example.com', 'mode' => 'dev']);
newsletterCustomer(['billing_email' => 'prev@example.com', 'mode' => 'prev']);
newsletterCustomer(['billing_email' => 'live@example.com', 'mode' => 'live']);
newsletterCustomer(['billing_email' => 'berater-shop@example.com', 'auth_user_id' => 99]);
newsletterCustomer(['billing_email' => 'userdeleted@example.com', 'user_deleted_at' => now()]);
newsletterCustomer(['billing_email' => 'softdeleted@example.com'])->delete();
$emails = newsletterBuild(['source' => 'customers'])->rows->pluck('email')->all();
expect($emails)->toEqualCanonicalizing(['ok@example.com', 'live@example.com']);
});
it('schließt gesperrte, gelöschte, Test- und Admin-Berater aus und kann Admins optional einbeziehen', function () {
newsletterConsultant(['email' => 'aktiv@example.com']);
newsletterConsultant(['email' => 'vip@example.com', 'admin' => 1]);
newsletterConsultant(['email' => 'admin@example.com', 'admin' => 2]);
newsletterConsultant(['email' => 'sysadmin@example.com', 'admin' => 5]);
newsletterConsultant(['email' => 'blocked@example.com', 'blocked' => 1]);
newsletterConsultant(['email' => 'testmode@example.com', 'test_mode' => 1]);
newsletterConsultant(['email' => 'predeleted@example.com', 'pre_deleted_at' => now()]);
newsletterConsultant(['email' => 'delete-7-alt@example.com']);
newsletterConsultant(['email' => 'softdeleted@example.com'])->delete();
$default = newsletterBuild(['source' => 'consultants'])->rows->pluck('email')->all();
$withAdmins = newsletterBuild(['source' => 'consultants', 'include_admins' => 1])->rows->pluck('email')->all();
expect($default)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com'])
->and($withAdmins)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com', 'admin@example.com']);
});
it('entfernt Duplikate über beide Quellen, Berater gewinnt vor Kunde und Opt-in vor ohne Opt-in', function () {
newsletterConsultant(['email' => 'Doppelt@Example.com']);
newsletterCustomer(['billing_email' => 'doppelt@example.com', 'billing_firstname' => 'Kunde']);
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne', 'subscribed' => 0]);
newsletterCustomer(['billing_email' => 'ZWEIMAL@example.com', 'billing_firstname' => 'Mit', 'subscribed' => 1]);
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne2', 'subscribed' => 0]);
$result = newsletterBuild(['source' => 'both', 'consultant_status' => 'all']);
$byEmail = $result->rows->keyBy('email');
expect($result->rows)->toHaveCount(2)
->and($byEmail['doppelt@example.com']['source'])->toBe('consultant')
->and($byEmail['zweimal@example.com']['firstname'])->toBe('Mit')
->and($result->stats['duplicates'])->toBe(3)
->and($result->stats['total'])->toBe(2)
->and($result->stats['customers'])->toBe(1)
->and($result->stats['consultants'])->toBe(1);
});
it('verwirft ungültige E-Mail-Adressen und zählt sie', function () {
newsletterCustomer(['billing_email' => 'kontakt@ kaputt.de']);
newsletterCustomer(['billing_email' => '#']);
newsletterCustomer(['billing_email' => 'gut@example.com']);
$result = newsletterBuild(['source' => 'customers']);
expect($result->rows->pluck('email')->all())->toBe(['gut@example.com'])
->and($result->stats['invalid'])->toBe(2);
});
it('filtert nach Sprache und behandelt fehlende Sprache wie Deutsch', function () {
newsletterCustomer(['billing_email' => 'de@example.com', 'language' => 'de']);
newsletterCustomer(['billing_email' => 'null@example.com', 'language' => null]);
newsletterCustomer(['billing_email' => 'es@example.com', 'language' => 'es']);
newsletterConsultant(['email' => 'berater-es@example.com', 'lang' => 'es']);
expect(newsletterBuild(['source' => 'both', 'language' => 'de'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['de@example.com', 'null@example.com'])
->and(newsletterBuild(['source' => 'both', 'language' => 'es'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['es@example.com', 'berater-es@example.com'])
->and(newsletterBuild(['source' => 'both', 'language' => ''])->rows)->toHaveCount(4);
});
it('filtert nach Land bei Kunden und Beratern', function () {
$at = newsletterCountry('AT');
newsletterCustomer(['billing_email' => 'de@example.com']);
newsletterCustomer(['billing_email' => 'at@example.com', 'billing_country_id' => $at->id]);
newsletterConsultant(['email' => 'berater-at@example.com'], ['country_id' => $at->id]);
newsletterConsultant(['email' => 'berater-de@example.com']);
$emails = newsletterBuild(['source' => 'both', 'country_ids' => [$at->id]])->rows->pluck('email')->all();
expect($emails)->toEqualCanonicalizing(['at@example.com', 'berater-at@example.com']);
});
it('filtert nach Opt-in', function () {
newsletterCustomer(['billing_email' => 'ja@example.com', 'subscribed' => 1]);
newsletterCustomer(['billing_email' => 'nein@example.com', 'subscribed' => 0]);
expect(newsletterBuild(['optin' => 'subscribed'])->rows->pluck('email')->all())->toBe(['ja@example.com'])
->and(newsletterBuild(['optin' => 'unsubscribed'])->rows->pluck('email')->all())->toBe(['nein@example.com'])
->and(newsletterBuild(['optin' => 'all'])->rows)->toHaveCount(2);
});
it('filtert nach Kaufstatus inklusive Zeitraum und aktivem Abo', function () {
$country = newsletterCountry();
$never = newsletterCustomer(['billing_email' => 'nie@example.com', 'has_buyed' => 0]);
$recent = newsletterCustomer(['billing_email' => 'kuerzlich@example.com']);
$old = newsletterCustomer(['billing_email' => 'alt@example.com']);
$abo = newsletterCustomer(['billing_email' => 'abo@example.com']);
foreach ([[$recent, now()->subMonths(2)], [$old, now()->subMonths(4)]] as [$shoppingUser, $createdAt]) {
ShoppingOrder::forceCreate([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => 0,
'country_id' => $country->id,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 6,
'total' => 50,
'subtotal' => 50,
'points' => 25,
'paid' => true,
'shipped' => 0,
'mode' => 'live',
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $abo->id,
'is_for' => 'customer',
'email' => $abo->billing_email,
'payone_userid' => 1,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $never->id,
'is_for' => 'customer',
'email' => $never->billing_email,
'payone_userid' => 2,
'clearingtype' => 'cc',
'active' => false,
'status' => 4,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
]);
$emails = fn (string $purchase) => newsletterBuild(['purchase' => $purchase])->rows->pluck('email')->all();
expect($emails('has_buyed'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com', 'abo@example.com'])
->and($emails('none'))->toBe(['nie@example.com'])
->and($emails('months_3'))->toBe(['kuerzlich@example.com'])
->and($emails('months_6'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com'])
->and($emails('active_abo'))->toBe(['abo@example.com']);
});
it('filtert nach zugeordnetem Berater bzw. Sponsor', function () {
$sponsor = newsletterConsultant(['email' => 'sponsor@example.com']);
newsletterCustomer(['billing_email' => 'zugeordnet@example.com', 'member_id' => $sponsor->id]);
newsletterCustomer(['billing_email' => 'ohne@example.com', 'member_id' => null]);
newsletterConsultant(['email' => 'firstline@example.com', 'm_sponsor' => $sponsor->id]);
expect(newsletterBuild(['source' => 'both', 'member_id' => (string) $sponsor->id])->rows->pluck('email')->all())
->toEqualCanonicalizing(['zugeordnet@example.com', 'firstline@example.com'])
->and(newsletterBuild(['source' => 'customers', 'member_id' => 'none'])->rows->pluck('email')->all())
->toBe(['ohne@example.com']);
});
it('filtert nach Anlagedatum und Firma', function () {
newsletterCustomer(['billing_email' => 'alt@example.com', 'billing_company' => 'Firma GmbH', 'created_at' => '2025-01-15 10:00:00']);
newsletterCustomer(['billing_email' => 'neu@example.com', 'billing_company' => '', 'created_at' => '2026-03-01 10:00:00']);
newsletterConsultant(['email' => 'berater-firma@example.com'], ['company' => 'Beratung UG']);
newsletterConsultant(['email' => 'berater-privat@example.com'], ['company' => null]);
expect(newsletterBuild(['created_from' => '2026-01-01', 'created_to' => '2026-12-31'])->rows->pluck('email')->all())
->toBe(['neu@example.com'])
->and(newsletterBuild(['source' => 'both', 'company' => 'with'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['alt@example.com', 'berater-firma@example.com'])
->and(newsletterBuild(['source' => 'both', 'company' => 'without'])->rows->pluck('email')->all())
->toEqualCanonicalizing(['neu@example.com', 'berater-privat@example.com']);
});
it('filtert Berater nach Status und Level und gibt den Status in der Zeile aus', function () {
$level = UserLevel::forceCreate(['name' => 'Starter', 'pos' => 1]);
newsletterConsultant(['email' => 'aktiv@example.com', 'm_level' => $level->id]);
newsletterConsultant(['email' => 'inaktiv@example.com', 'active' => 0]);
newsletterConsultant(['email' => 'anderes-level@example.com']);
$active = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'active']);
$inactive = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'inactive']);
$all = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all']);
$byLevel = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all', 'level_ids' => [$level->id]]);
expect($active->rows->pluck('email')->all())->toEqualCanonicalizing(['aktiv@example.com', 'anderes-level@example.com'])
->and($inactive->rows->pluck('email')->all())->toBe(['inaktiv@example.com'])
->and($inactive->rows->first()['status'])->toBe('inactive')
->and($inactive->rows->first()['optin'])->toBe('contract')
->and($all->rows)->toHaveCount(3)
->and($byLevel->rows->pluck('email')->all())->toBe(['aktiv@example.com'])
->and($byLevel->rows->first()['level'])->toBe('Starter');
});
it('liefert Export-Kopfzeile und Zeilen in Vorlagen-Reihenfolge, optional nur die neun Vorlage-Spalten', function () {
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
newsletterCustomer([
'billing_email' => 'export@example.com',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_company' => 'Muster GmbH',
'member_id' => $consultant->id,
]);
$service = app(NewsletterListService::class);
$rows = newsletterBuild()->rows;
$fullHeaders = $service->exportHeaders(false);
$templateHeaders = $service->exportHeaders(true);
$templateRows = $service->exportRows($rows, true);
$fullRows = $service->exportRows($rows, false);
expect($templateHeaders)->toBe(['Emailadresse', 'Vorname', 'Nachname', 'Anrede', 'Straße', 'PLZ', 'Wohnort', 'Firma', 'Berater'])
->and($fullHeaders)->toHaveCount(17)
->and($fullHeaders[9])->toBe('Land')
->and($templateRows[0])->toBe(['export@example.com', 'Erika', 'Muster', 'Frau', 'Musterweg 1', '12345', 'Musterstadt', 'Muster GmbH', 'Anna Sponsor'])
->and($fullRows[0])->toHaveCount(17)
->and($fullRows[0][9])->toBe('DE')
->and($fullRows[0][11])->toBe('Kunde')
->and($fullRows[0][14])->toBe('ja');
});
it('baut den Filter mit Defaults und akzeptiert nur erlaubte Werte', function () {
$default = NewsletterListFilter::fromArray([]);
$custom = NewsletterListFilter::fromArray([
'source' => 'unsinn',
'language' => '',
'country_ids' => ['3', '', '3', 'x'],
'purchase' => 'months_6',
'member_id' => '12',
'created_from' => '2026-01-01',
'created_to' => 'kein datum',
'include_admins' => '1',
]);
expect($default->source)->toBe('customers')
->and($default->language)->toBe('de')
->and($default->optin)->toBe('all')
->and($default->consultantStatus)->toBe('active')
->and($custom->source)->toBe('customers')
->and($custom->language)->toBeNull()
->and($custom->countryIds)->toBe([3, 0])
->and($custom->purchaseMonths())->toBe(6)
->and($custom->memberIdValue())->toBe(12)
->and($custom->createdFrom?->format('Y-m-d H:i:s'))->toBe('2026-01-01 00:00:00')
->and($custom->createdTo)->toBeNull()
->and($custom->includeAdmins)->toBeTrue()
->and($default->cacheKey())->not->toBe($custom->cacheKey());
});