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:
parent
ef7e333bd4
commit
d21d94a060
26 changed files with 3664 additions and 0 deletions
394
dev/_tickets/entwicklungsplan-newsletter-listen.md
Normal file
394
dev/_tickets/entwicklungsplan-newsletter-listen.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue