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>
303 lines
13 KiB
PHP
303 lines
13 KiB
PHP
<?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>';
|
||
}
|
||
}
|