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>';
}
}