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,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.