Compare commits

...

326 commits

Author SHA1 Message Date
783b9da9b6 docs: verschobene Doku-Pfade in Code-Kommentaren nachgezogen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:53:08 +00:00
6a4c4d8837 docs: dev-Doku für den Live-Betrieb neu sortiert — Archiv + Betrieb + Roadmap
Das System ist seit 20.07.2026 live; die dev-Doku spiegelt jetzt den
Betriebszustand statt der Release-Vorbereitung:

- dev/betrieb/: Deploy-Runbook (neu: §10 Betrieb nach Go-Live mit
  SFTP-Deploy-Checkliste inkl. queue:restart-Pflicht und den
  Betriebs-Erkenntnissen der Go-Live-Woche), Legacy-API-Parität, Umami.
- dev/roadmap.md: offene Nachlauf-Punkte (Go-Live-Mails, Merge nach main,
  Stripe-Test-Artefakte, Key-Rotation, Alt-System-Abschaltung) und
  Ideen-Backlog.
- dev/archive/: abgeschlossene Phasen — Migration-2026-Planung,
  Cutover-Runbook (mit Erledigt-Vermerk), Release-Plan/QA-Checkliste,
  Optimierungsphasen, Frontend-Konzepte + Responsive-Audits vom 16.07.,
  Altsystem-Reste.
- dev/README.md als Wegweiser; gitignore-Pfad der SQL-Dumps mitgezogen
  (dev/archive/migration-2026/sql/ bleibt untracked, ebenso dev/migration/
  und dev/api/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:52:38 +00:00
58c773b153 web: Domain-spezifische Favicons und App-Icons für alle drei Marken (R5.4)
Eigene Icon-Sets unter public/icons/{pressekonto,presseecho,businessportal24}
(SVG-Favicon + Touch-/App-Icons); ThemeHelper mappt das aktive Theme auf den
Icon-Ordner (Portal/Admin nutzt den pressekonto-Satz), alle Layout-Heads
binden Favicon + apple-touch-icon domain-abhängig ein.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:50:21 +00:00
42e56a7d81 web: www-Hosts leiten kanonisch per 301 auf die Apex-Domain
Ploi kann nur Pfad-Redirects, daher als App-Middleware (früh im globalen
Stack): GET/HEAD auf www.<domain> → 301 auf die Apex-Domain mit Pfad und
Query. Unsichere Methoden werden nicht umgeleitet (301 verwirft den Body)
— die beantwortet die App dank Host-Normalisierung weiterhin selbst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:47:13 +00:00
acf67211bf web: www-Hosts lösen aufs Apex-Domain-Theme auf statt aufs Portal-Fallback
Die Domain-Auflösung matchte den Host exakt gegen domain_name —
www.presseecho.de fiel dadurch aufs Portal-Default-Theme zurück, dessen
theme-portal.css im Web-Build nicht existiert. Alle Route::view-Seiten
ohne Controller (Impressum, Datenschutz, AGB, FAQ, …) antworteten auf
www-Hosts mit 500 (ViteException, Dauereintrag im Live-Log); die
Controller-Seiten traf es nicht, weil WebPortalContext den Host tolerant
matcht. resolve() normalisiert Hosts jetzt (lowercase, www. entfernt) auf
beiden Seiten des Vergleichs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:13:33 +00:00
3d10cbf709 web: Startseiten-Feed cachen + Index gegen Filesort der Feed-Query
Der Livewire-Feed lief auf jeder Startseiten-Impression ungecacht; seine
ORDER-BY-published_at-Query kann wegen portal IN (…) keinen bestehenden
Index zur Sortierung nutzen und filesortete ~10-20k Zeilen (live gemessen
2,3 s pro Aufruf — die Startseite blieb dadurch trotz Seitendaten-Cache
bei 6-9 s TTFB). Zwei Maßnahmen:

- Neuer Index (language, status, deleted_at, published_at): Backward
  Index Scan statt Filesort, Query 2 ms statt 2,3 s — greift auch für
  ungecachte Livewire-Interaktionen (Zeitraum-Filter, tiefe Seiten).
- Feed-Seiten 1-3 laufen über WebPageCache (SWR wie die übrigen
  Seitendaten); gecacht werden nur items + total, der Paginator wird pro
  Request neu gebaut (keine Host-URLs des Cache-Erzeugers im Cache).
  Unbekannte timeframe-Werte aus der URL fallen auf 'all' zurück und
  erreichen weder Query noch Cache-Key. Der doppelte Gesamt-COUNT pro
  Request entfällt; der 'Mehr laden'-Zähler nutzt jetzt den gefilterten
  Total des Paginators.

Lokal mit Live-Datenbestand: Startseite kalt 875 ms (vorher 2,5 s),
warm 11 ms (vorher 72 ms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:53:09 +00:00
bd4ad855ba web: Stale-while-revalidate-Cache für die öffentlichen Seitendaten
Start-/Kategorieseiten bauten nach Ablauf der 5-Minuten-TTL im Besucher-
Request neu (~25-30 Archiv-Queries über ~100k PMs) — Long-Tail-Rubriken
waren dadurch praktisch bei jedem Aufruf langsam. WebPageCache nutzt jetzt
Cache::flexible([300, 3600]): Innerhalb 5 Min frisch wie bisher, danach
wird bis zu 1 h sofort der alte Stand serviert und der Neuaufbau läuft
nach der Response (deferred). HomePageData/CategoryPageData/TickerData
laufen dafür über die gemeinsame WebPageCache-Konvention (Keys unverändert),
die Hub-Kennzahlen über flexible([3600, 14400]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:29:15 +00:00
5011f28f88 legacy: API-Key-Import löst E-Mail-gemergte User über legacy_import_map auf
Kunden, die in beiden Altportalen existierten, tragen nach dem Merge nur
das zuletzt importierte Portal in users.legacy_portal/legacy_id — der
Schlüssel des anderen Portals fand dadurch keinen Besitzer und wurde als
'Kein User-Mapping' übersprungen (Presseecho-Key → 410). Die Import-Map
kennt beide Zuordnungen und wird jetzt zuerst befragt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:19:42 +00:00
d212230a39 docs: Cutover-Delta nutzt --since, Medien-Delta-Hinweis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:03:35 +00:00
a0c2e663dc legacy: --since-Delta für legacy:import + Importer setzen Timestamps wieder korrekt
--since=<Zeitpunkt> schränkt den Quell-Scan auf Legacy-Zeilen ein, deren
created_at ODER updated_at im Fenster liegt (users/companies/contacts/
press-releases; Kategorien und link-associations bleiben ungefiltert).
Kombiniert mit --force werden geänderte Bestandszeilen aktualisiert —
damit dauert ein Tages-Delta Sekunden statt eines Volldurchlaufs über
150k+ Zeilen.

Dazu Bugfix, der den Delta erst rund macht: User-/Company-/Contact-
Importer übergaben created_at/updated_at innerhalb von updateOrCreate —
die Felder sind nicht fillable und wurden stillschweigend verworfen
(Live-Befund: NULL-Timestamps, bisher nur durch legacy:fix-timestamps
geheilt). Jetzt explizit per forceFill wie im PressReleaseImporter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:03:07 +00:00
22b7bd38b7 docs: Runbooks auf den echten Migrationsstand nachgezogen (16.07.)
Cutover-Runbook: Legacy-DBs liegen bei KAS und sind als lokale Snapshot-
Kopien eingespielt (Delta-Läufe brauchen vorher frische Dumps); privatize-
media läuft mit --prune-missing; retire-agency-type als Satellit ergänzt;
verlorene Presseecho-PM-Bilder (~22k) als bewusste Entscheidung dokumentiert.
Deploy-Runbook: Seeder-Liste um LegalDocumentSeeder/RejectionCategorySeeder
ergänzt, billing:sync-stripe-plans nach frischem Seeding, Tinker-Befehl für
die Admin-Rolle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:59 +00:00
9be473162a billing: Abo-Kündigungen aus dem Stripe-Portal im Aktivitäts-Verlauf
Kündigung zum Periodenende, deren Rücknahme (customer.subscription.updated,
Transition über previous_attributes.cancel_at_period_end) und das endgültige
Abo-Ende (customer.subscription.deleted) erscheinen jetzt als neuer Typ
"Abo" unter Admin ▸ Reports ▸ Verlauf — bisher war dort nur die Zahlung
sichtbar, die Portal-Kündigung blieb unsichtbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:47 +00:00
7971656025 legacy: fix-timestamps liest Legacy-Schema-Namen aus der DB-Config statt hartkodiert
Die Cross-Schema-JOINs zeigten fest auf `presseecho`/`businessportal` —
auf dem Live-Server heißen die Schemas aber anders (lokale Snapshot-Kopien
der KAS-Datenbanken). Der Schema-Name kommt jetzt aus der database-Config
der jeweiligen Legacy-Connection (DB_DATABASE_PRESSEECHO/_BUSINESSPORTAL),
derselben Quelle, die auch legacy:import nutzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:39 +00:00
3151b1f38a legacy: privatize-media überspringt unmigrierte Legacy-Bildzeilen, --prune-missing löscht sie
Der Legacy-Import legt pro Alt-Bild eine Zeile mit rohem Legacy-Dateinamen
als path an; legacy:migrate-media biegt sie auf die echte Datei um. Zeilen,
deren Datei im Alt-System verloren ging (Live-Befund: ~22k bei Presseecho),
blieben als disk=public-Leichen liegen und ließen den Privatizer mit
RuntimeException abbrechen. Jetzt: Skip + Warnhinweis statt Abbruch;
--prune-missing löscht solche Zeilen endgültig, wenn ihre Datei auf keiner
Disk existiert. Der Integritäts-Assert für migrierte Pfade bleibt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:31 +00:00
a1302994c5 billing: Veröffentlichungsnachweis mit Titelbild, Bildrechten und Rich-Text
Der Nachweis-PDF bindet nun das Titelbild der PM kompakt oben ein – links
das Bild mit den beim Upload erfassten Bildrechten (Copyright, Urheber,
Lizenz, Quelle, KI-Kennzeichnung), rechts daneben die Angaben zur
Veröffentlichung als Tabelle. Der Volltext übernimmt Fett-/Kursiv-
Formatierung (F2/F3/F4), und Fortsetzungsseiten beginnen den Inhalt mit
Abstand unterhalb des Seitenrands statt direkt am Seitenanfang.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 12:05:46 +00:00
07d89a0e6a web: Preise-Seite zeigt unter Weitere Leistungen nur noch Depublizieren
Inhaltliche Korrektur (8 Credits) und Update/Ergänzung (4 Credits)
existieren als buchbare Leistungen noch nicht — sie folgen erst mit dem
Bookings-/Magic-Link-Rework und fliegen bis dahin von der Preisliste.

Depublizieren wird als Direktkauf in Euro abgerechnet (Stripe-Checkout
über billing.depublication_price_cents), nicht in Credits: Die Seite
zeigt jetzt 35 € aus derselben Preisquelle wie der Kauf-Flow statt der
falschen 35 Credits. Ohne konfigurierten Preis verschwindet der Block
komplett (wie das Angebot auf der PM-Detailseite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:34:22 +00:00
2ea64ad222 web: Strukturierte Daten (JSON-LD) und SEO-Metas fürs öffentliche Frontend
Ergänzt die bislang fehlenden strukturierten Daten auf den Presse-Seiten:

- PM-Detailseite: NewsArticle + BreadcrumbList als JSON-LD sowie
  article:published_time/-section/-tag-Metas und og:image:alt — nur auf
  der öffentlichen Seite, die noindex-Vorschau bleibt ohne Markup.
- Kategorie-Seiten: BreadcrumbList plus hreflang de/en über die
  lokalisierten Slugs (CategoryPageData liefert dafür neu
  categoryAlternateSlugs, mit View-Fallback für gecachte Alt-Daten).
- Newsroom: Organization-Markup (Logo, Website als sameAs, Boilerplate).
- Portal-Homepages: WebSite mit SearchAction (Sitelinks-Searchbox) und
  Organization.
- Layout: og:locale und twitter:image ergänzt; neue Sections meta_extra
  und structured_data.

Neue Komponente x-web.structured-data rendert JSON-LD mit JSON_HEX_TAG,
damit "</script>" in Titeln/Teasern das Script-Tag nicht aufbrechen kann.
JSON-LD-URLs werden wie der Canonical aus der Portal-Basis gebaut
(unabhängig von forceRootUrl), damit Host und Canonical übereinstimmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:27:45 +00:00
dec524989b web: Responsive-/Mobile-Fixes fürs öffentliche Frontend
Behebt strukturelle Mobile-Bugs (320/390px) über presseecho,
businessportal24 und den pressekonto-Hub hinweg:

- Focus-/Kategorie-/Newsroom-Hero: Text im Flow (min-h + flex) statt
  fester Höhe, responsive Typo — Overlay überläuft nicht mehr nach oben.
- Quality-Summary: Grid stackt auf Mobile (behebt Seiten-Overflow).
- Hub: Utility-Bar mit Wrap, Header mit Mobile-Burger, Footer stackt,
  Architektur-Diagramm als gestapelte Mobile-Variante.
- Sticky-Header (Portal): fehlende .header-sticky-CSS ergänzt und Script
  mit Platzhalter, damit der Header beim Scrollen sichtbar bleibt.
- Header-Overflow bei 320px entschärft (Portal + Hub); Hub-Hero-H1
  verkleinert, damit die Wortmarke "businessportal24" umbricht.
- Mobile-Nav um Preise/FAQ/API ergänzt; Industry-Index-Dichte,
  Touch-Targets und Preise-Einzel-PM-Block für kleine Screens verbessert.
- Homepages/Shared: px-8 → px-4 sm:px-6 lg:px-8.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:33:49 +00:00
9c67fc6c14 docs: Cutover-Runbook für den Go-Live (Legacy-Import & Umschaltung)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:07:13 +00:00
3e855375b1 web: Kategorie-Badge im Feed bricht auf Mobile sauber um
Lange Kategorienamen liefen auf schmalen Viewports aus dem Layout —
Badge darf jetzt umbrechen und ist mobil auf 8rem begrenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:07:05 +00:00
a3671bab90 emails: Portal-Wortmarken im Mail-Header ergänzen
Rechts neben der pressekonto-Wortmarke erscheinen jetzt presseecho
und businessportal24 — Empfänger ordnen System-Mails damit sofort
den Portalen zu. Hellere Markenfarben auf Hub-Blau für Lesbarkeit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:06:59 +00:00
3a11963990 admin: Aktivitäts-Verlauf unter Reports ▸ Verlauf
Chronologisches Protokoll der Systemereignisse als neue Admin-Seite
(/admin/reports/activity) mit Filter nach Ereignistyp, Zeitraum und
Suche. Erfasst ab jetzt fortlaufend:

- Registrierungen inkl. Weg (Formular, Google, Magic-Link)
- Anmeldungen inkl. Weg (Passwort, Google, Magic-Link, Impersonation)
- PM-Statuswechsel mit Ergebnis/Begründung (zentral im Status-Log-Hook;
  KI-/Scheduler-Wechsel erscheinen als „System")
- Zahlungseingänge (Rechnungsspiegelung) und Credit-Aufladungen
- Formular-Eingänge: Kontakt, Rechtsanfrage, Widerruf, Kündigung,
  Newsletter-Anmeldung (Observer auf den Modellen)

Schreiben ist Best-Effort über den ActivityLog-Service — ein
fehlgeschlagener Verlaufseintrag bricht nie den fachlichen Ablauf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:00:58 +00:00
a574898ca9 billing: Stripe-Belege auf Deutsch und sprechende Produktnamen
Drei Stellschrauben für die Stripe-Rechnung (Befund Test-Buchung:
englischer Beleg, Position nur 'Starter'):
- stripePreferredLocales() am User: Stripe rendert Rechnung/Quittung
  in der Kontosprache (de-DE, bei language=en englisch)
- Checkout-Session mit locale in der Kontosprache statt Auto-Erkennung
- billing:sync-stripe-plans vergibt sprechende Produktnamen inkl.
  PM-Kontingent und zieht sie bei bereits verknüpften Plänen an den
  bestehenden Stripe-Produkten nach (Preise bleiben unverändert)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:25:07 +00:00
37bcf731bf billing: Blindkopie der Zahlungsbestätigung an LEGAL_CONTACT_EMAIL
Interner Beleg über jede versendete Buchungsbestätigung im
Rechts-/Service-Postfach (config legal.contact_email).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:00:20 +00:00
e32431e948 billing: Zahlungsbestätigung per Mail nach jeder Buchung
Sobald eine bezahlte Stripe-Rechnung in den STR-Kreis gespiegelt
wird, erhält der Kunde eine Bestätigungsmail mit Rechnungsnummer
und Beträgen — deckt Tarif (inkl. Verlängerung), Einzel-PM und
Credits ab. Transaktionsmail ohne Opt-out; durch die idempotente
Spiegelung genau eine Mail pro Beleg, Gutschriften bleiben stumm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:36:37 +00:00
425467bbec billing: asynchrone Zahlarten (PayPal, SEPA) korrekt erfüllen
checkout.session.completed feuert bei asynchronen Zahlarten schon
nach dem Redirect mit payment_status=unpaid — der Listener hat
trotzdem sofort erfüllt (Credits gutgeschrieben / Kauf bezahlt),
auch wenn die Zahlung später scheitert. Erfüllung jetzt nur bei
payment_status paid/no_payment_required, zusätzlich reagiert der
Listener auf checkout.session.async_payment_succeeded; Fehlschläge
werden geloggt, der Kauf bleibt wiederholbar auf Pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:27:09 +00:00
67f6906b87 billing: Stripe-Webhook von der Basic-Auth ausnehmen
Mit BASIC_AUTH_ENABLED=true (Staging) bekam Stripe für jeden
Webhook-Call ein 401 — Zahlungen wurden nie erfüllt. Der Endpoint
ist durch die Signaturprüfung (STRIPE_WEBHOOK_SECRET) geschützt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:55:32 +00:00
0b01929f80 profil: Speichern-Buttons für Persönliche Daten und Konto & Sicherheit
Die beiden oberen Panels auf /admin/me/profile hatten als einzige
keine eigene Speichern-Zeile — Sprach- und Kontaktdaten-Änderungen
ließen sich nur über die Buttons weiter unten sichern. Footer wie in
den übrigen Panels ergänzt, per flex/mt-auto bündig am Panel-Ende.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:12:05 +00:00
ce59cbbf4a auth: Selbst-Registrierungen nicht mehr als Legacy-Bestand einstufen
Die Register-Komponente setzte kein registration_type, wodurch der
DB-Default existing_legacy griff und jeder Neukunde im Admin als
Legacy-Bestand erschien. Jetzt wird explizit Company gesetzt —
konsistent mit dem CustomerAccountProvisioner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:11:53 +00:00
700f5e6131 dashboard/errors: Onboarding-Card „Erste Schritte" & Hub-Styling der Fehlerseiten
Neue aufklappbare "Erste Schritte"-Card als erste Card im Kunden-Dashboard,
die neue Nutzer durch Profil, Rechnungsadresse, Firma und erste PM führt und
sich ausblendet, sobald alle Schritte erledigt sind. Zusätzlich die
gemeinsame Fehlerseiten-Vorlage (404/403/419/500/503) an das Hub-Design
angepasst (Palette, Gold-Akzent, Hub-Blau-Button), weiterhin self-contained
ohne Vite-Assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:07:36 +00:00
74f71f7a58 docs: Audit-Protokoll, KI-Kennzeichnung & Release-Notizen
- dev/optimize/optimize-14-07-2026.md: Security-/Code-Audit inkl.
  Fix-Protokoll (Befunde gegen den Code verifiziert).
- docs/weiteres/KI-Kennzeichnung.md, Pm Tracken.md, Decision-Updates und
  Release-/Entwicklungsplan aktualisiert.

Hinweis: Der Audit-Bericht enthält keine echten Geheimnisse (nur die
öffentlichen Key-Präfixe), aber eine vollständige Befundübersicht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:50:41 +00:00
e51fe6ffa9 chore: persönliche VS-Code-Workspace-Datei aus dem Repo entfernen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:50:31 +00:00
830aca5149 admin: Betriebsüberwachung im Backend (Queue-Worker & Deployment-Checks)
Neue Reports-Seite (settings:manage) zeigt den Queue-Zustand (ältester
wartender Job, Worker aktiv/gestoppt, fehlgeschlagene Jobs) und
Deployment-Checks als Ampel: Permissions ausgerollt, APP_DEBUG,
Session-Secure-Cookie, ausstehende Medien-Privatisierung. Damit ist die
für Magic-Link-Login und fristrelevante Mails kritische Queue-Abhängigkeit
aus dem Admin sichtbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:57 +00:00
23cbd0bfe2 performance: Caching, deferred Tracking & externe Timeouts (H7, M12, M13)
- Navigations-Chrome (CategoryNavigation), Suche- und Newsroom-Seitendaten
  300s gecacht; gemeinsamer WebPageCache-Helper (eine TTL/Key-Konvention).
- Cache-Poisoning über ?page= geschlossen (Key an Paginator ausgerichtet,
  kein withQueryString im Cache); Newsrooms cachet nur den ungefilterten
  Fall und die ersten Seiten (kein unbegrenzter Key-Raum).
- Detailseiten schreiben Hits/View-Events deferred nach der Response
  (M12); OpenAI-/Markt-/USt-ID-Aufrufe mit Connect-Timeouts und
  begrenzten Retries; Admin-Memo-Caches konsistent invalidiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:47 +00:00
5a492926dd newsletter: Bestätigung/Ownership beim Re-Abo korrigieren (M7)
updateOrCreate ersetzt durch eine Ownership-Prüfung: ein fremder,
gesetzter Eigentümer wird abgelehnt (409), bestätigte Abos werden nicht
still ent-bestätigt. Verwaiste Datensätze (user_id NULL nach Konto-
Löschung/Legacy-Import) dürfen neu gebunden werden, statt dauerhaft
gesperrt zu bleiben.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:31 +00:00
fe1525b9ff auth/mail: Magic-Link-2FA (M1) + Mailversand über die Queue
- Magic-Link-Login übergibt bei aktiviertem 2FA an die 2FA-Challenge,
  statt sie zu umgehen (M1).
- Bestätigungs-/Benachrichtigungs-Mails (Kontakt, Recht, Kündigung,
  Widerruf, Magic-Link) laufen als ShouldQueue+afterCommit über die Queue,
  damit die Response nicht am SMTP-Roundtrip hängt.

Hinweis: Der Queue-Worker muss im Betrieb laufen und überwacht werden, da
fristrelevante Bestätigungen darüber zugestellt werden (siehe
Betriebsüberwachung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:21 +00:00
8f517e993d security: Stored-XSS über page_url im Kontaktformular schließen (H3)
page_url aus dem anonymen Kontaktformular wird auf url:http,https
validiert; das Admin-Backend rendert nur http(s)-Links klickbar, sonst
als Text. Verhindert javascript:-URIs, die beim Admin-Klick im
Admin-Kontext liefen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:05 +00:00
77f8f9cb35 security: Rechteausweitung schließen & Admin-Panel feingranular gaten (K1, H1, H2, M8)
- users:manage/roles:manage auf allen User-/Rollen-Masken erzwungen;
  is_super_admin aus $fillable entfernt und nur per autorisiertem
  forceFill setzbar; zentraler Gate::before-Super-Admin-Kurzschluss (K1/M8).
- Nicht-Super-Admins können Super-Admin-Konten nicht mehr bearbeiten (Fix#2);
  Impersonation mit Rangprüfung, keine Token-Erstellung während Impersonation (H1).
- Destruktive/verwaltende Admin-Aktionen (CRM/Billing/Compliance/Struktur)
  hängen an neuen admin-only Permissions; Editor behält reine Content-Rechte (H2).
- Kanonischer Permission-Katalog (App\Authorization\Permission) als Single
  Source of Truth für Seeder und Monitoring.

Deployment: `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen
Umgebungen ausführen (neue Permissions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:50 +00:00
39e13d6d84 security: Host-Whitelist & Legacy-API-Key-Übergabe härten (H5, M11)
- trustHosts erlaubt nur die konfigurierten Domains (plus localhost/IP
  für Health-Checks) und verwirft fremde Host-Header, sodass
  X-Forwarded-Host keine URL-Generierung mehr vergiften kann (H5).
- Legacy-API-Keys nur noch per Header (Bearer/X-ApiKey); die alte
  Query-/Body-Übergabe wird nicht mehr zur Auth akzeptiert, erhält aber
  weiterhin den 410-Migrationshinweis statt eines opaken 401 (M11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:28 +00:00
6250b0bab4 media: PM-Medien privat ausliefern + Vorschau-Signatur (M4, M5, Fix#4/#5)
- Neue PM-Bilder/Anhänge liegen auf der privaten local-Disk; Auslieferung
  über kontrollierte Media-Routen mit gemeinsamer Zugriffslogik
  (AuthorizesPressReleaseMediaAccess). Öffentliche Medien werden aus den
  geladenen Modellen ermittelt (weniger Queries) und länger gecacht.
- Anonyme Token-Vorschau lädt Bilder über temporär signierte URLs (Fix#5).
- API-Resource baut Variant-URLs über die Media-Route statt /storage
  (Fix#4). Storage-Schicht prüft zusätzlich den serverseitigen MIME-Type (M5).
- press-releases:privatize-media migriert Bestandsmedien.

Deployment: einmalig `php artisan press-releases:privatize-media` ausführen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:06 +00:00
6e84266f90 press-releases: Publish-Rechte, Quota-Races & Slug vereinheitlichen
- Publish/Reject/Archive und changeStatus erzwingen konsistent
  press-releases:publish; changeStatus schloss die Lücke, über die das
  Status-Modal die Prüfung umging (Fix#1/H2).
- publish()/changeStatusFromAdmin sperren PM- und User-Zeile
  transaktional; QuotaExceededException wird in Admin-Publish und im
  Auto-Publish-Job abgefangen (Warnung statt 500 / bleibt in Review, Fix#3/M2).
- Slug-Erzeugung nur noch über HasUniqueSlug (generateUniqueSlug +
  saveWithUniqueSlugRetry), redundanter Controller-Helper entfernt (M3).
- API-/Legacy-Text wird vor der Persistenz sanitisiert (M6);
  Scheduler/Legacy-Tracking ignorieren soft-gelöschte PMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:47:51 +00:00
aae456954a billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)
- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook-
  Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und
  prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei
  at-least-once-Webhooks, H4).
- Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form
  (CreditTopup, ManualInvoice).
- Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über
  boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim
  Löschen der Rubrik entfernt.

Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits
Duplikate enthält (die Unique-Migration bricht sonst bewusst ab).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:47:21 +00:00
3295f47556 deps: Sicherheits-Updates (Framework/Symfony/Guzzle) einspielen (H6)
composer update auf gepatchte Minor-Versionen gegen die im Audit
gemeldeten Advisories (u. a. symfony/http-kernel CVE-2026-45075,
symfony/mime CVE-2026-45067, laravel/framework <12.60). composer audit
ist danach clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:47:07 +00:00
6f96e4a4cc PM-Detailseite: Inhaltspruefung (Gruen/Gelb/Rot) + Qualitaets-Score sichtbar
- Neue Kachel "Inhaltspruefung" in Status & Verlauf (Badge je Ergebnis,
  "Ausstehend" ohne Pruefung); bei Gelb neutraler Hinweis, dass die
  Boost-Platzierung gruen gepruefte Meldungen voraussetzt
- Content-Score-Panel des Editors in gemeinsame Komponente
  x-content-score-panel extrahiert und auf der Detailseite wiederverwendet
  (Score/100, Stufe, Abstand zur naechsten Schwelle)
- PressReleaseClassification::badgeClass() (ok/warn/err)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:59:33 +00:00
4f2be1e0a8 PM-Uebersicht: Rubrik unter Portal, Boost-moeglich-Hinweis, gruener Haken
- Rubrik (lokalisiert) direkt unter den Portal-Pills; Kategorie wird in
  accessiblePageFor() eager geladen (kein N+1)
- Status-Spalte: laeuft kein Boost, wirbt eine veroeffentlichte gruene PM
  mit "Boost moeglich ->" (Link zur PM-Seite mit den Add-ons)
- Gruen geprueft: kleiner gruener Haken neben dem Titel (Tooltip
  "Inhaltspruefung: Gruen")

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:23:07 +00:00
0cddf5fbdc Add-on-Panel zeigt freie Boost-Plaetze je Stufe
slotAvailability() liefert zusaetzlich free/limit (knappste Ausgabe, ohne
den eigenen gehaltenen Slot); solange buchbar, steht unter den Kauf-Buttons
"Noch X von Y Plaetzen frei." — bei laufendem Boost weiterhin nur der
Verlaengerungs-Hinweis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:12:05 +00:00
842d6a2bd0 Doku: Boost-Platzierung entschieden + umgesetzt (Slot-Grenzen, Kategorie-Boost)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:28:03 +00:00
6796fa472e Preise-Seite: zwei Boost-Stufen inkl. Slot-Hinweis in der Leistungsnote
Oeffentliche Preisliste rendert beide Staffeln (Startseiten-Boost 12/20/35,
Kategorie-Boost 6/10/18); die Boost-Note der Leistungsbeschreibung nennt
jetzt beide Stufen und die begrenzten Plaetze je Seite (de/en). Muss mit in
die anwaltliche Freigabe des Rechtstexte-Pakets [EXTERN].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:28:03 +00:00
0474d22347 Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz
Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5
gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren
Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur
Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost
(12/20/35) erscheint weiter auf Start- UND Branchenseite.

- boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum;
  Preise/Slots in config/credits.php (boost_category, boost_slots)
- BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie
  zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem
  freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern
  unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches
- Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit
  Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest
- Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar
  "Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated);
  Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:28:03 +00:00
0677728d05 Doku: Boost-Platzierung offene Punkte (Hero-Slider, Boost-Begrenzung) fuer Montag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:26:49 +00:00
6be387c9fb Boost-Buchung invalidiert gecachte Start- und Branchenseiten sofort
Statt bis zu 300 s Cache-TTL zu warten, verwirft der BoostService nach
der Buchung die Home-/Kategorie-Caches der betroffenen Ausgaben (inkl.
Eltern-Rubrik) — die bezahlte Platzierung ist direkt sichtbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:22:24 +00:00
cc61bf21d9 Add-on-Modal: Pflicht-Schalter + Verlaengerungs-Hinweis bei laufendem Boost
Kauf-Button bleibt deaktiviert, bis der Bestaetigungs-Schalter aktiv ist
(serverseitig abgesichert). Laeuft bereits ein Boost, warnt das Modal,
dass die Buchung die Laufzeit verlaengert, mit altem und neuem Enddatum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:17:32 +00:00
d5b806443e E-Mail-Praeferenzen: Opt-out je Mail-Typ + One-Click-Unsubscribe ohne Login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 17:15:05 +00:00
484e062264 KI-Klassifikation: Substanzpruefung gegen Gruen-Durchrutscher
Gruen setzt jetzt einen erkennbaren Nachrichtenkern voraus; Test-/
Platzhalter-Texte sind explizit K6-Verstoss (rot), sehr duenne Inhalte
mindestens gelb. Behebt Befund, dass inhaltsleere Test-PMs (Score 4)
nach der Prompt-Ueberarbeitung gruen durchliefen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 17:14:12 +00:00
47c1cac5f5 PM-Liste: Hinweis auf laufende Boost-Platzierung unter dem Status
Badge "Boost bis TT.MM.JJJJ" in der Status-Spalte; Boost-Enden der
Seite in einer Query aggregiert statt isBoosted() je Zeile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:11:38 +00:00
df6b24518c Web: Boost-Platzierung auf Start- und Branchenseite ausspielen
Geboostete PMs besetzen Lead- und Top-Slots vor der Chronologie
(HomePageData/CategoryPageData ueber boosted()-Scope) und tragen das
Badge "Top-Platzierung". Abgelaufene Boosts wirken nicht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:05:10 +00:00
0665553c88 Werbeplaetze reaktiviert: Eigenwerbe-Slots oeffnen das Kontakt-Widget
Die im Spoke-Frontend-Audit entfernten Anzeige-Flaechen (focus-ad,
feed-ad, Inline-Ad) kehren als dezente Eigenwerbung zurueck:

- neue Komponente x-web.ad-slot im frueheren "ANZEIGE"-Stil mit Hinweis
  "Hier koennte Ihre Werbung stehen" (DE/EN)
- eingebaut an den drei frueheren Stellen: Hero-Sidebar der Startseiten,
  Feed nach der dritten Meldung, PM-Detailseite nach der
  Unternehmensbeschreibung (nicht in der privaten Vorschau)
- Klick oeffnet das schwebende Kontakt-Widget mit vorgewaehltem neuen
  Thema "Werbung buchen" (ContactMessageTopic::Advertising); Anfragen
  landen wie gewohnt in der Admin-Queue "Kontakt"

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 16:53:02 +00:00
0cda19458d KI-Kennzeichnung: 2 Optionen im Submit-Modal + Labels auf Detailseite
Submit-Modal auf zwei Auswahloptionen reduziert (kein Label vs.
"Ganz oder ueberwiegend mit generativer KI erstellt"); alle vier
Enum-Werte bleiben fuer API/Bestand gueltig und mappen per formOption()
auf die zwei Gruppen. Oeffentliche Detailseite rendert jetzt Text-Label
("KI-generiert: Nach Angaben ...") und Bild-Badge (KI-generiert/
KI-bearbeitet, abgedunkelter Hintergrund) — fehlte dort bisher komplett.
Bildnachweis-Vorschlag ohne "Foto:"-Praefix (stand sonst doppelt).
Einreich-Toast + FAQ: automatische Pruefung in Minuten statt 24 h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 16:51:23 +00:00
93d7f6d480 Add-ons: Kaufbestaetigung vor Credit-Abbuchung + deutlichere Buttons
Boost-Laufzeiten und Nachweis oeffnen jetzt ein Bestaetigungs-Modal mit
Preis, Guthaben und Restsaldo; erst "Kostenpflichtig buchen" bucht ab.
Boost-Buttons primary mit explizitem Kauf-Label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:44:05 +00:00
b26dc9057c Admin-PM-Liste: KI-Pruefungs-Modal + geschaerfte KI-Prompts
- Spalten umsortiert (Aktionen, Datum, KI-Pruefung zuerst); Pruefen-Button
  nur noch Icon, oeffnet Detail-Modal mit Klassifikation, Begruendung,
  Content-Score inkl. Faktor-Breakdown und Re-Check (wire:poll bis Queue durch)
- Content-Score: Bild-Punkte (10) systemseitig statt KI (nur ein Upload-Bild
  moeglich), Quellen 10->5, Pressestil 20->25; Breakdown wandert ins Audit-Log
- Klassifikations-Prompt prueft gegen aktive Ablehnungskategorien (K1-K6,
  rejection_categories): klarer Verstoss => direkt rot mit Kategorie-Code

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 16:37:47 +00:00
9cb53b1e17 API: nicht-numerische Routen-IDs als 404 statt TypeError/500
Ein vergessener Postman-Platzhalter (PM_ID) lief als String in die
int-typisierten Controller-Parameter. Numerische where-Constraints auf
den v1- und /service-Gruppen; Legacy faellt in den 410-Catch-all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:20:01 +00:00
7a573ca02b Mail-Routing: API-Einlieferung ueber Status-Log-Quelle erkennen
Der Kontotyp ist kein verlaesslicher Marker (connektar ist agency,
nicht apiuser) — submitForReview traegt jetzt die Quelle ('customer'
Panel, 'api' Legacy/v1) ins Status-Log, notifyAuthor prueft den
letzten Review-Uebergang. Kein Schema-Change noetig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:43:05 +00:00
d9f25aecaf S4: legacy:seed-api-testdata — API-Testkunde nach connektar-Muster
Idempotenter Command (nur local/staging): aktiver Legacy-API-User mit
Grandfathering, importiertem X-ApiKey, api:immediate-publication,
Firma + Pressekontakt; gibt Key + curl-Sammlung des /service-Flows aus.
Live gegen die lokale App verifiziert (Firma -> Kontakt -> PM sofort
published -> GET 200 im Alt-Format).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:22:58 +00:00
6a45ebf607 Legacy-API-Paritaet S1+S2: connektar-Flow verhaltensgleich + KI-Schalter
Eigene Compat-Controller (Api/Legacy) fuer die 5 real genutzten
/service-Endpoints (Prod-Log-Beleg): Company-/Kontakt-Create (liefen
410!), PM-Create, Bild-Upload, published-only-GET — verschachtelte
Alt-Params, 201+Location+{}, Alt-Fehlerformat, Hit-Zaehlung,
Legacy-Land-Mapping.

- Anlegen = Einreichen (Alt-Verhalten); Sofort-Publish via neuer
  Permission api:immediate-publication (WS-7: connektar zuweisen)
- Admin-Schalter 'KI-Pruefung Legacy-API' (app_settings-Tabelle,
  Toggle auf /admin/press-releases): an = review + KI-Routing
- Admin-PM-Liste: KI-Spalte (Ampel-Badge) + manueller Pruef-Button
  ohne Routing fuer ungepruefte PMs
- Mails bei API-Einlieferern an Pressekontakt, sonst Firmen-Mail,
  Fallback Einlieferer (User-Entscheidung 10.07.)
- Tests: LegacyServiceParityTest (connektar-Flow end-to-end);
  Suite 1182 gruen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:09:40 +00:00
5a352425d7 API-Legacy-Paritaet: Arbeitsdokument mit Alt-Vertrag, Log-Auswertung, Schrittplan
- Alt-Vertrag komplett aus dem Symfony-Code extrahiert (Routen, Params
  verschachtelt, Response-Shapes, Sofort-Publish-Logik, Fehlerformat)
- Prod-Logs (dev/api, User) ausgewertet: ein Client (connektar),
  5 Endpoints; Company-/Kontakt-Create heute 410 -> muessen nachgebaut
  werden; Sofort-Publish beim POST belegt
- Entscheidungen 10.07.: Sofort-Publish + Admin-Schalter KI-Pruefung,
  getrennte Legacy-/v1-API, Ablehnungs-Rueckkanal spaeter
- dev/api/ enttrackt (.gitignore, Produktivdaten analog SQL-Dumps)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:40:24 +00:00
b18598584c Web-Frontend: /api-Doku mit KI-Deklarationsfeldern (curl-Beispiele + Hinweis)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:44:20 +00:00
92af015532 API: KI-Deklaration als v1-Pflichtfeld, Legacy-Default fuer /service (Entscheidung 10.07.)
Befund: v1 hatte keine KI-Deklarationsfelder — Submit lief seit der
KI-Kennzeichnungspflicht (09.07.) immer auf 422 und haette alle
Legacy-API-Kunden blockiert (Suite uebersah es: Factory deklariert).

- v1-Submit verlangt ai_text_declaration; der Call gilt dokumentiert
  als verbindliche Bestaetigung (Art. 50 KI-VO) und schliesst die
  Archiv-Kenntnisnahme (AGB par. 13) ein
- Bild-Upload verlangt ai_declaration (original/ai_generated/ai_edited)
- Legacy-/service-Aliasse defaulten auf none/original — das
  API-Kunden-Mailing (R6.4) weist auf die Erklaerungswirkung hin
- OpenAPI v1.yml + lang-Hinweis aktualisiert; Tests erweitert
  (Submit/Image/LegacyCompatibility)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:41:24 +00:00
3843e534da Web-Frontend: Kartenkomponenten auf PressRelease-Helper umgestellt (R4.3)
@php-Bloecke von feed-item, feed-top-item, focus-side-item, focus-hero,
kategorie, newsroom, release-detail, veroeffentlichen nutzen die neuen
Model-Helper — gerendertes Markup unveraendert (Web-Suite 215 gruen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:57:26 +00:00
01e175e138 R4.3: Karten-Metadaten-Logik als PressRelease-Praesentations-Helper
localizedCategoryName/cardImageUrl/teaserText/wordCount/readTimeMinutes/
compactPublishedLabel ersetzen die 5x kopierte @php-Logik der
Kartenkomponenten; Lesezeit-Formel vereinheitlicht (ceil -> round in
HomePageData-Spotlight). Tests: Web/PressReleaseCardHelpersTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:57:26 +00:00
459b339a9b Dev-Stack: Queue-Worker lauscht auch auf der classification-Queue
composer run dev startete queue:listen ohne --queue-Angabe, also nur
default — die KI-Klassifikationsjobs (Queue classification) blieben
lokal liegen und PMs steckten in der manuellen Review fest, obwohl
das Auto-Routing (Rot -> Ablehnung, Gelb/Gruen -> Auto-Publish)
laengst implementiert ist. Prod-Runbook war bereits korrekt
(--queue=classification,default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:50:34 +00:00
460e092c02 QA-Checkliste B-G vertieft (Nachpruefung 10.07.)
- Alle referenzierten Beleg-Tests verifiziert, Suite 1156 gruen
- B-G um Features 07.-10.07. ergaenzt: Kontakt-Queue/-Widget,
  Ablehnungsbegruendung, KI-Kennzeichnung (Submit/API/Label),
  Tarifwechsel, Add-ons, USt/eVatR, Rechnungsseite, Depublikation
  komplett, PM-Tracking, Boersenticker, Monitoring-Alerts
- Frontend-Umbau-Vorbehalt in F aufgehoben (Umbau committet 09.07.)
- Befund dokumentiert: Page-Data-Cache ohne Publish-Invalidierung
  (max. 5 Min. Verzug, bewusster Tradeoff, kein Blocker)
- Release-Plan: naechste-Schritte-Block nachgezogen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:54:56 +00:00
0603fe5368 R2.5: legal-request-Hinweistexte final (Wortlaut aus Meldeverfahren v0.1)
- Meldung/DSGVO/Persoenlichkeitsrecht auf die am 06.07. freigegebenen
  /melden-Texte gezogen: kostenlose manuelle Pruefung, unverzuegliche
  Eingangsbestaetigung mit Vorgangsnummer (sofern E-Mail), Entscheidung
  mit Begruendung; DSGVO ohne pauschale 30-Tage-Zusage, stattdessen
  Bestaetigungslink/Nachweis wie im Meldeverfahren
- ENTWURF-TODO entfernt, Querverweis auf /melden ergaenzt
- Render-Test erweitert (Vorgangsnummer, kostenlos, melden-Link)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:11:28 +00:00
34f0e0f134 Web-Frontend: Aufrufe-Anzeige + Share-Tracking auf der PM-Detailseite
- Meta-Zeile zeigt Aufrufe (hits inkl. aktuellem Aufruf) in beiden
  Hero-Varianten; in der Vorschau ausgeblendet
- Teilen-Buttons melden den Kanal an release.share (fetch keepalive,
  CSRF-Meta); Link-kopieren trackt nur bei erfolgreichem Copy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:56:55 +00:00
8f5970436b PM-Tracking: Ereignis-Log fuer Aufrufe + Shares (press_release_events)
- press_release_events (type view/share, channel, ohne Personenbezug):
  Model/Enums/Factory, Cascade-Delete an der PM
- Detailaufruf loggt View-Event zusaetzlich zum Legacy-hits-Zaehler
  (bleibt bewusst einfacher Page-Request-Zaehler, User-Vorgabe 10.07.)
- POST press-release/{slug}/share (throttle 30/min, nur archiv-sichtbare
  PMs des Portals) loggt Teilen-Klicks je Kanal (LinkedIn/X/E-Mail/Copy)
- Kundenpanel-PM-Seite: neue Kachel Geteilt mit Kanal-Aufschluesselung
- lang: web.detail.views (de/en)
- Tests: Web/PressReleaseTrackingTest (7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:56:42 +00:00
60d0d9b103 Release-Doku: Commit-Session vermerkt + Wiedereinstieg fuer morgen
7-Commit-WIP-Aufraeumung + R5.3/Buchungsdaten im Stand-Block, R6.5-WIP-Punkt
abgehakt, neuer Block 'Wo es morgen weitergeht'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:54:16 +00:00
d1939f6f2a Web-Frontend: Umbau Spokes/Hub, Kontakt-Widget (contact-launcher)
Startseiten/Feeds/Kategorie/Rechtsseiten ueberarbeitet, Kontakt-Modal durch
schwebendes contact-launcher-Widget ersetzt, legal-language-notice ergaenzt;
Platzhalter-SVGs + Theme-CSS angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:45:58 +00:00
fd8e050cb7 Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n
Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt:
- Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory,
  Admin-Queue (livewire/admin/contact-messages), ContactController
  persistiert das Anliegen zusaetzlich zur Brand-Mail
- Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf
  press_releases
- Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests)
- i18n-Auslagerung lang/de|en (auth, passwords, validation, static)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:38:11 +00:00
c6b5ec8479 Release-Doku: Stand 09.07. nachgezogen; Runbook FULLTEXT-Fenster + Monitoring
Stand-Block 07.-09.07. (Billing/USt, Tarifwechsel, Agentur-Attribution,
KI-Kennzeichnung, Tages-Fixes), R5.3 abgehakt, Commit-Zahlen aktualisiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:40 +00:00
6984846233 Monitoring (R5.3): Betriebs-Alerts fuer Scheduler-/Queue-Ausfaelle
OperationalAlert loggt critical + Best-Effort-Mail an MONITORING_ALERT_EMAIL
(Fallback LEGAL_CONTACT_EMAIL, feature-flagged). Alle 6 Scheduler-Tasks mit
onFailure, Queue::failing-Listener; .env.example: LOG_STACK=daily als Prod-Soll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:40 +00:00
ac870a4076 Buchungsseite: Buchungs- und Verlaengerungsdatum des Abos aus Stripe
Cashier spiegelt die Periode nicht lokal; neuer SubscriptionPeriodResolver
holt start_date + current_period_end (Item-Ebene) gecacht, Fallback null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:28 +00:00
a254bbd6a3 Depublikation: Credit-Preis auf 35 (O6), Preis-Tests nachgezogen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:28 +00:00
07e0bc598e API-Doku: OpenAPI-Spec aus public/ statt geloeschtem docs/ ausliefern
Der docs/-Mount kam nicht mit; die Route /docs/api/v1 lief dadurch auf 500.
Liest jetzt public/api/v1.yml mit 404-Guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:34:28 +00:00
f11e8aab7c PM-Editor UX: Kategorie in die Schreibfläche, Bild-Upload als Modal-Flow
Kategorie-Pflichtfeld direkt unter die Firma statt in die Sidebar.
Titelbild-Upload öffnet ein Modal: erst Bild wählen, dann erscheinen
die gestrafften Schritte (Herkunft & Bildnachweis mit Vorschlag aus
dem Urheber, Personen & Rechte, Bestätigung), Optionales eingeklappt.
Bildrechte-Checkbox im Einreichen-Modal nur noch mit eigenem Titelbild.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:55:21 +00:00
807fbc1b80 KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration für Text & Bilder
Selbstdeklaration des KI-Einsatzes je PM (Text) und je Bild inkl.
Submit-Gate im PressReleaseService, versioniertem Audit-Trail
(press_release_ai_declarations) und öffentlichem KI-Label in
Einreicher-Stimme. Bestätigung im Submit-Modal ist Pflicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:54:43 +00:00
74e82eb643 Form-Hooks: Fehl-Scroll aufs erste Feld bei Live-Validation behoben
Zwei Bugs im Scroll-zum-Fehler-Helper: '[wire\:submit]' matchte das
<form> selbst, sodass jeder Klick in ein Feld den Scroll armierte
(sichtbar beim USt-ID-Live-Check im Profil); und der leere, versteckte
Flux-Error-Container unter jedem Feld galt wegen Whitespace als
':not(:empty)' — fokussiert wurde dadurch grundlos das erste Feld.
Enter-Submit wird jetzt über das submit-Event erkannt, Fehlerfelder
nur noch bei echtem Fehlertext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:29:12 +00:00
3297179226 USt-ID vor jeder Buchung per eVatR gegenprüfen, Stripe-Tax-IDs abgleichen
USt-IDs laufen ab — Kunden bestellten anderswo über veraltete IDs
steuerfrei. Vor jedem Checkout wird die Profil-USt-ID jetzt einmal per
eVatR (BZSt) geprüft (Ergebnis 6 h gecacht): Eine definitiv abgelehnte
ID geht nicht an Stripe, nicht mehr passende eu_vat-Einträge am
Stripe-Customer werden entfernt — Stripe Tax rechnet dann wieder mit
Steuer statt Reverse Charge. Fail-open bei API-Ausfall und deutschen
IDs (eVatR prüft nur ausländische EU-IDs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:28:56 +00:00
fe8bd2b931 Buchungsseite: Verlauf mit Kauftyp, Erfolgsmeldungen grün
„Eingelöste Käufe" nennt jetzt Kauftyp und PM-Titel (z. B.
„Depublikation: <Titel>") über das neue kompakte shortLabel() am
SinglePurchaseType. Die Checkout-Erfolgsbanner (Buchung, Tarifwechsel,
vorgemerkter Wechsel) und die Extra-PM-Bestätigung nutzen das
etablierte grüne Success-Muster (ok-soft) statt Hub-Blau.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:25:24 +00:00
f6882b621a Depublikation: Kauf als eingelöst markieren, Rechnung & Bestätigungs-Mail
Drei Lücken im bezahlten Depublikations-Flow (T12) geschlossen:

- Webhook setzt den Kauf jetzt auf status=consumed (vorher nur
  consumed_at) — erst damit erscheint er unter „Eingelöste Käufe".
- Einmalkäufe (Depublikation, Einzel-PM, Credit-Topup) aktivieren
  invoice_creation im Stripe-Checkout: Ohne das Flag erzeugt Stripe im
  Payment-Mode keine Rechnung und die STR-Spiegelung lief ins Leere.
  Abo-Checkouts bleiben unverändert (Parameter dort unzulässig).
- Neue Bestätigungs-Mail PressReleaseDepublished an den Käufer
  (nicht zwingend der PM-Autor) nach vollzogener Depublikation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:25:12 +00:00
d57f6b5c66 Agentur-Attribution: Plan abgehakt, Abnahme-Status 08.07. dokumentiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:51:56 +00:00
3ff95bc93a UI: Agentur-Angaben im Profil, Firmen-Typ-Auswahl entfernt
A5 des Umbaus Agentur-Attribution → User-Profil: Kunden-Profil erhält
den Abschnitt „Agentur-Angaben" (Feld Agentur-/Verteilername, öffentlich
als Übermittler sichtbar). Firma-Anlegen (Kunde + Admin) und
Admin-Firma-Bearbeiten kommen ohne Typ-Feld aus — Neuanlagen sind immer
type=company. Firmen-Übersicht zeigt kein Typ-Label mehr. Admin-User-
Formulare pflegen agency_name; die Detailseite zeigt den Übermittler
als Badge. CompanyFactory erzeugt keine zufälligen Agentur-Typen mehr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:44:17 +00:00
59ac67a4c8 companies:retire-agency-type: Firmen-Typ „Agentur" abschaffen
A4 des Umbaus Agentur-Attribution → User-Profil: Agentur-Firmen mit
PMs oder Pressekontakten werden zu type=company umtypisiert (sie sind
faktisch Newsrooms), leere Agentur-Firmen soft-gelöscht. Geschützt vor
Löschung (nur Umtypisierung): Mitgliedschaften Dritter in company_user
oder Verknüpfungen in user_payment_option_company. --dry-run +
JSON-Report; im Deploy-Runbook nach legacy:migrate-agency-names
eingereiht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:33:39 +00:00
85fddbfdf8 Attribution: Übermittler aus users.agency_name statt Agentur-Firma
A3 des Umbaus Agentur-Attribution → User-Profil: Eager Loading der
Detailseite/Vorschau lädt user:id,agency_name; die Legacy-Relation
PressRelease::submittingAgency() (Pseudo-Firma type=agency via
owner_user_id) entfällt. Tests decken Verteiler-Fall, Namensgleichheit
und PM ohne Übermittler ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:30:41 +00:00
a62a5eea6e PM-Detailseite: Attributions-Zeile Urheber/Übermittler
Rendert unter dem Inhalt „Original-Inhalt von {Firma} und übermittelt
von {Übermittler}" (Legacy-Konvention), Übermittler aus dem Profilfeld
users.agency_name; bei Namensgleichheit kollabiert die Zeile zu
„Inhalt übermittelt von …", ohne Übermittler zu „Original-Inhalt von …".

Nur die Attributions-Hunks aus dem laufenden Web-Umbau; die übrigen
Working-Tree-Änderungen an View/Sprachdateien bleiben unangetastet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:30:20 +00:00
402fa4a38b legacy:migrate-agency-names: Agentur-Namen → users.agency_name
Übernimmt je Owner den Namen seiner Legacy-Agentur-Firma(en) ins neue
Profilfeld (A2 des Umbaus Agentur-Attribution → User-Profil). Regeln:
zuletzt aktualisierter Datensatz gewinnt bei Mehrfach-Namen (alle
Varianten im Report), bereits gesetzte Werte werden nie überschrieben,
Owner-lose Agentur-Firmen werden nur reportet. --dry-run + JSON-Report
nach storage/app/private/migration/; im Deploy-Runbook (Erst-Einrichtung)
nach dem Firmen-/User-Import eingereiht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:25:04 +00:00
e457e7a81c Profilfeld users.agency_name für Übermittler-Attribution
Erster Schritt des Umbaus Agentur-Attribution → User-Profil
(dev/agentur-attribution/entwicklungsplan.md, A1): Die Übermittler-
Angabe wird Profil-Eigenschaft des einliefernden Accounts statt
Pseudo-Firma vom Typ „agency".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:20:32 +00:00
881a583bec Depublikation: abgebrochener Checkout blockiert nicht mehr
Der Klick auf "Kostenpflichtig depublizieren" legte sofort einen
Pending-Kauf an; wer bei Stripe abbrach, sah danach dauerhaft "liegt
bereits ein Depublikations-Vorgang vor". Jetzt sperrt nur ein bezahlter
Vorgang — ein offener Pending-Kauf wird beim nächsten Klick
wiederverwendet (kein Duplikat), und die Detailseite zeigt das Angebot
weiterhin an.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:07:32 +00:00
28de92e18b PM-Detailseite: Direktlinks zur öffentlichen Seite je Portal
Veröffentlichte PMs zeigten im Backend nur den Status-Badge, ohne Weg
zur öffentlichen Seite. Im Seitenkopf stehen jetzt Direktlinks — bei
Portal::Both einer je Portal (QA-Befund 07.07.).

Die URL-Logik ist als PressRelease::publicPortalLinks() zentralisiert
(vorher doppelt in der Published-Mail und fehlerhaft im Nachweis-PDF:
dort fehlte das /de/press-release/-Segment, die dokumentierte URL lief
ins Leere; bei Both nennt der Nachweis jetzt beide URLs). Edition folgt
der PM-Sprache statt hart „de".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:00:40 +00:00
930760a056 single_purchases: type-ENUM um depublication erweitern
Die create-Migration baut das ENUM dynamisch aus
SinglePurchaseType::cases(); Datenbanken von vor dem T12-Case kannten
depublication daher nicht und der Depublikations-Checkout brach mit
"Data truncated for column 'type'" ab. Die Migration gleicht das ENUM
per MODIFY COLUMN auf den aktuellen Case-Stand ab (nur MySQL; frische
DBs bekommen die volle Liste ohnehin über die create-Migration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:51:56 +00:00
d7f17bbc76 PM-Detailseite: Depublikations-Sektion ans Seitenende verschoben
Der seltene Ausnahme-Service stand zwischen Add-ons und Inhalt; jetzt
steht er als letzte Sektion unter dem Inhalt (QA-Befund 07.07.). Dazu
zwei UI-Tests: Sektion + Unternehmer-Bestätigungs-Protokoll auf der
Detailseite, Ausblenden ohne Preis bzw. bei laufendem Vorgang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:35:42 +00:00
9600a4f887 Add-ons: Modal statt Toast bei fehlendem Credit-Guthaben
Scheiterte ein Boost- oder Nachweis-Kauf am Wallet-Guthaben, gab es nur
einen flüchtigen Toast ohne Weg zur Lösung. Jetzt öffnet sich ein Modal
mit Kosten, Guthaben und fehlender Differenz sowie einem Direktlink
"Credits aufladen" zur Wallet-Sektion der Buchungsseite (neuer
#credits-Anker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:30:57 +00:00
0f8f0b2d31 Einreichen-Modal: Panel-Struktur und fixierter Kontingent-Badge
Das Modal war ein unstrukturierter Stapel aus zwei Boxen und losen
Switches; der Kontingent-Badge wurde vom Flex-Layout zusammengedrückt
und brach um ("3 /" / "3"). Jetzt: drei beschriftete Panels (Rechtliche
Hinweise, PM-Kontingent, Ihre Bestätigungen) im portalweiten
panel/panel-head-Stil, die Switches als getrennte Zeilen mit
Fortschrittszähler (x / 4) im Panel-Kopf, Badge mit shrink-0 und
whitespace-nowrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:16:56 +00:00
acd241b3f2 PM-Editor: Ablehnungsbegründung direkt auf der Bearbeiten-Seite anzeigen
Bei abgelehnten Pressemitteilungen stand die Begründung bisher nur auf
der Detailseite. Der Editor zeigt jetzt dasselbe Hinweis-Panel (Begründung
+ Ablehnungsdatum) über der Schreibfläche, damit der Kunde beim
Überarbeiten sieht, was zu ändern ist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:09:42 +00:00
c4ccc751e9 Rechnungs-Seite: Archiv-Filter entfernt, Altsystem-Archiv nur für Bestandskunden
QA-Befund 07.07.: Die Filter-/Suchzeile unter den aktuellen Rechnungen
entfällt; das Rechnungsarchiv (Altsystem) erscheint nur noch, wenn der
User dort tatsächlich importierte Rechnungen hat — Neukunden sehen es
nicht mehr. Seitenbeschreibung passt sich entsprechend an.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:53:05 +00:00
80e04e26e0 Downgrades ohne Gutschrift: Tarifwechsel nach unten erst zum Periodenende
QA-Entscheidung 07.07.: Eine Buchung ist nicht rückabwickelbar — es darf
nie eine negative Proration-Rechnung oder ein Stripe-Guthaben entstehen.

- StripeCheckoutService::isDowngrade: sofortiger Wechsel nur, wenn der
  Periodenpreis des Ziel-Tarifs den aktuellen erreicht (Wechsel-Rechnung
  damit garantiert >= 0); sonst Downgrade
- scheduleDowngrade: Stripe Subscription Schedule stellt das Abo zum Ende
  der bezahlten Periode um (proration_behavior none), hebt eine
  vorgemerkte Kündigung auf und spiegelt die Vormerkung lokal
  (neue Spalten scheduled_stripe_price/scheduled_change_at/
  stripe_schedule_id an subscriptions)
- Sofort-Upgrade löst eine bestehende Vormerkung vorher auf (release)
- Webhook customer.subscription.updated räumt die Vormerkung nach
  Vollzug ab
- Buchungs-Modal unterscheidet Upgrade (sofort, Differenz-Rechnung) und
  Downgrade (Vormerkung, keine Zahlung heute); Banner + Hinweis auf der
  Paket-Karte zeigen den vorgemerkten Wechsel mit Datum
- Kontingent-Sperre aus 4b7f6d3 entfernt — ohne Gutschrift gegenstandslos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:38:57 +00:00
388ca0e620 Downgrade-Vorkehrungen: Kontingent-Sperre beim Tarifwechsel, Gutschriften nicht spiegeln
QA-Befund 07.07.: Der Wechsel zurück in einen kleineren Tarif erzeugte
eine Proration-Gutschrift (negative Stripe-Rechnung) — die STR-Spiegelung
crashte an den unsigned-Spalten (Webhook-500), und der Wechsel war trotz
verbrauchtem Monatskontingent möglich.

- ProcessStripeWebhook: Belege ohne positiven Rechnungsbetrag
  (Gutschrift/Null-Rechnung) werden geloggt übersprungen statt gespiegelt
- CheckoutController::swap: Wechsel gesperrt, wenn der Monatsverbrauch
  das Kontingent des Ziel-Tarifs übersteigt (möglich ab Monatserstem)
- Buchungs-Modal: erklärt die Sperre inkl. Datum statt Bestätigungs-
  Button; Proration-Hinweis deckt jetzt auch die Guthaben-Richtung ab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:22:30 +00:00
14994e4748 PM-Vorschau: echtes Portal-Design, Direkt-Button und Portal-Switch
Der Vorschau-Link (/pm-vorschau/{token}) renderte eine schlichte
Standalone-Seite, die dem spaeteren Erscheinungsbild nicht entsprach.
Jetzt rendert er die echte Detailseite (web.release-detail) im Theme
des Zielportals:

- Controller wendet Domain-/Theme-Konfig des Zielportals an (Sprache
  der PM, Links zeigen auf die Portal-Domain), kein Hit-Zaehler
- Gelbes Vorschau-Banner mit Status und Link-Gueltigkeit; noindex per
  Meta-Tag + X-Robots-Tag-Header, Canonical entfaellt bei noindex
  (neue robots-Section im Web-Layout)
- Teilen-Buttons teilen den Vorschau-Link selbst (oeffentliche URL
  existiert vor Veroeffentlichung noch nicht)
- PMs fuer beide Portale: ?portal=... waehlt die Ansicht, Umschalter
  im Banner; im Kundenpanel je Portal ein Vorschau-Button, sonst ein
  einzelner "Vorschau oeffnen"-Button (Link-Feld jetzt kopierbar)
- Alte press-release-preview-View entfernt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 13:11:57 +00:00
db65c2ed7d Suche: Volltext-Index + Fix fuer doppelte Alpine-Instanz
Archiv-Suche lief auf grossen Datenbestaenden langsam und war im Browser
komplett tot: app.js startete ein eigenes Alpine, Livewire injizierte
zusaetzlich seins - der DOM-Morph brach mit "Alpine.transaction is not a
function" ab (keine Rubrik klickbar, Suchbegriff nicht aenderbar).

- app.js buendelt jetzt Livewire + Alpine aus livewire.esm,
  @livewireScriptConfig im Web-Layout stoppt die Auto-Injektion
- MySQL-Volltext-Index (Boolean-Mode, +wort*) statt LIKE ueber alles;
  LIKE-Fallback fuer SQLite-Tests und kurze Tokens
- Firmen-/Rubrik-Treffer als ID-Listen statt orWhereHas (OR + EXISTS
  hebelte den Volltext-Index aus)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 13:03:14 +00:00
b3780e0fea Tarifwechsel im Buchungs-Modal: Abo-Swap mit anteiliger Verrechnung
Tarif-Karten bieten bei bestehendem Abo jetzt 'Zu diesem Tarif
wechseln' (pro Intervall, aktueller Tarif deaktiviert). Der Wechsel
laeuft ueber das bekannte Bestaetigungs-Modal (T1neu-Checkbox) als
POST auf die neue checkout.swap-Route: Cashier swapAndInvoice stellt
sofort um, verrechnet anteilig und hebt eine vorgemerkte Kuendigung
auf. SCA-Faelle leiten auf die Cashier-Bezahlseite. QA-Befund 07.07.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:58:32 +00:00
af156608b1 Checkout: Wallet-Daten duerfen Profil-Rechnungsadresse nicht ueberschreiben
customer_update: auto liess Apple Pay den zuvor synchronisierten
Stripe-Customer ueberschreiben (Kartenname + nur Land) — die Rechnung
trug den falschen Empfaenger. Mit Profil-Rechnungsadresse bleibt der
Customer jetzt unangetastet; nur ohne Adresse erfasst Stripe sie
selbst im Checkout (inkl. USt-ID-Abfrage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:33:48 +00:00
304c5817b5 Checkout: Profil-Rechnungsadresse als Stripe-Rechnungsempfaenger
Die lokal gepflegte Rechnungsadresse stand nur bei der Erstanlage am
Stripe-Customer; der Checkout erzwang die Neueingabe und ueberschrieb
sie — auf der Rechnung stand die getippte Adresse statt der Profildaten
(QA-Befund 07.07.). Jetzt: Customer-Sync (Name/Firma, Adresse, USt-ID)
vor jedem Checkout, stripeName()-Hook liefert Firma bzw. Rechnungsname
statt Login-Anzeigename, billing_address_collection auf 'auto' (keine
Zwangs-Neueingabe bei hinterlegter Adresse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:09:05 +00:00
5166fc7b40 Rechnungsseite: neue STR/MAN-Rechnungen gelistet, STR bei Stripe verlinkt
Die Kundenseite /admin/me/invoices zeigte bisher nur das Legacy-Archiv.
Neue Sektion "Aktuelle Rechnungen" mit Netto/USt/Gesamt und Status;
STR-Rechnungen oeffnen die gehostete Stripe-Rechnungsansicht ueber
einen Redirect-Endpunkt (URL wird je Aufruf frisch geholt, Zugriff nur
fuer den Eigentuemer). KPIs zaehlen neu + Archiv zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:01:12 +00:00
2a8fd0179e STR-Spiegelung: USt aus total_taxes lesen (Stripe-API ohne tax-Feld)
Aktuelle Stripe-API-Versionen liefern kein Top-Level-`tax` mehr auf der
Invoice — die Spiegelung buchte 0 USt und markierte die Rechnung als
netto (QA-Befund 07.07., STR-00001). Fallback-Kette: tax (alt) →
total_taxes (aktuell) → total_tax_amounts (Übergangsname).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:42:31 +00:00
54213cac64 Stripe Checkout: automatic_tax aktiviert (USt fehlte in der Session)
Cashier::calculateTaxes() greift nur bei direkter Abo-Erstellung, nicht
bei Checkout-Sessions — Stripe zeigte darum nur den Nettopreis
(QA-Befund 07.07.). Session-Optionen setzen jetzt automatic_tax +
tax_id_collection; Ad-hoc-Buchungen (Credits, Depublikation) laufen
statt ueber checkoutCharge ueber price_data mit tax_behavior=exclusive,
da automatic_tax das verlangt. Stripe Tax im Testmode eingerichtet
(Origin Bielefeld, DE-Registrierung); Live-Schritt in WS-7 ergaenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:25:28 +00:00
d1c976372e Buchungs-Modal: Dark-Mode-lesbare Preistabelle + aufgeraeumtes Layout
Preistabelle und Trenner nutzen Alpha-Farben (black/white) statt
Theme-Tokens, die im Dark Mode gegen den Modal-Hintergrund untergingen;
--color-bg-subtle war zudem nirgends definiert (Gesamt-Zeile ohne Bg).
Unternehmer-Bestaetigung als abgesetzter Consent-Block, Fusszeile mit
Trennlinie, Subtitle in den Kopf integriert. QA: A5 abgehakt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:13:55 +00:00
4fe0882dea QA-Checkliste: A1-A4 manuell abgenommen (07.07., inkl. 2FA-Fixes)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:01:46 +00:00
b42e0e3f62 2FA-Challenge: Fehlversuch crasht nicht mehr (Route two-factor.login fehlt)
Fortifys FailedTwoFactorLoginResponse leitet auf die Route
two-factor.login um, die bei views => false nie registriert wird
(RouteNotFoundException). Eigener Response leitet zurück auf die
Volt-Challenge-Seite und meldet den Fehler auf Deutsch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:58:17 +00:00
15387d0a3f QA-Befunde A4: 2FA-Bestätigungsschritt + sicherer E-Mail-Wechsel
- 2FA: Einrichtung verlangt jetzt die Bestätigung mit einem App-Code
  (Fortify confirm-Semantik); vorher blieb two_factor_confirmed_at
  leer und der Login fragte nie nach der Challenge.
- Recovery-Codes nur noch einmalig sichtbar (nach Bestätigung bzw.
  Neu-Erzeugung), nicht mehr bei jedem Seitenaufruf.
- E-Mail-Änderung als Pending-Flow: Bestätigungslink geht an die NEUE
  Adresse, die alte bleibt bis zum Klick aktiv — kein Aussperren durch
  Tippfehler und keine Registrierungs-Notice mehr nach dem Ändern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:53:24 +00:00
b7bbf6df4e Panel-Navigation: Buchungen zu Finanzen, neue Gruppe Portale
- "Buchungen & Add-ons" aus "Mein Bereich" in die Finanzen-Gruppe verschoben
  (vor Rechnungen)
- Neue Gruppe "Portale" nach Konto: externe Links auf die Startseiten von
  presseecho und businessportal24 (URLs aus config/domains.php, neuer Tab)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:02:47 +00:00
1904ea588b Platzhalter-Eintraege ("spaeter") aus der Panel-Seitennavigation entfernt
Statistiken, Credits & Tarif, Zahlungsarten und Benachrichtigungen waren
als ausgegraute Platzhalter sichtbar — fuers Release raus (User 06.07.);
die Features sind ohnehin nachgelagert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:00:55 +00:00
339ae5de70 Plan: Adress-Import, Key-Import-Verifikation und Git-Hygiene 06.07. dokumentiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:37:42 +00:00
2c7b5564d1 Produktiv-SQL-Dumps enttrackt (Security-Review-Befund R6.2)
dev/migration 2026/sql/ enthaelt echte Kundendaten aus den Altportalen —
per git rm --cached enttrackt und ignoriert; die Dateien bleiben lokal
liegen (Import-Rehearsal R6.3 braucht sie). Entscheidung 06.07.2026:
ohne History-Bereinigung, solange das Repo privat bleibt. docs/legal/
(Rechtstext-Quellen, Arbeitsstand des Users) ebenfalls ignoriert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:36:47 +00:00
bfad13af7d Spoke-Frontend-Audit: Fake-Inhalte raus, Newsletter-Rueckbau, echte Boersendaten, EN-Lokalisierung
Frontend-Umbau (Kevin, 06.07.2026), Ergebnis des businessportal24-Audits:

- Fake-Anzeigen und Mock-/Fallback-Daten entfernt (focus-ad, feed-ad,
  highlight-card, press-release-card, featured-releases, press-releases-grid);
  Sektionen rendern nur noch mit echten Daten oder gar nicht
- Newsletter-Strip und Newsletter-Box zurueckgebaut (Feature nachgelagert,
  vor Reaktivierung DOI-Flow noetig)
- Echte Boersendaten: MarketDataService (Yahoo-Finance-Quotes, config/market.php,
  Cache) + market:refresh alle 15 Min. im Scheduler; live-ticker blendet sich
  ohne Daten aus
- EN-Lokalisierung der Kern-Seiten via lang/de/web.php + lang/en/web.php;
  Carbon-Locale folgt app()->getLocale()
- Web-Tests auf den neuen Frontend-Stand nachgezogen (Suite: 993 gruen)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:36:23 +00:00
3320aaaa85 Rechnungsadressen der Bestandskunden aus dem Legacy-Archiv uebernehmen
Befund 06.07.: Der MAN-Rechnungslauf ueberspringt Vereinbarungen ohne
Rechnungsadresse — der Bestandsschutz laeuft dann am Periodenende faktisch
aus. Der Grandfather-Import nahm die Adressen bisher nicht mit, weil die
Altportale sie nur als Freitext-Blob fuehren.

- legacy:import-billing-addresses: uebernimmt Adressen der grandfathered
  Kunden aus der Quell-Rechnung im Archiv (pdf_payload.billing_address),
  nie ueberschreibend, mit Dry-Run und Report der manuell nachzupflegenden
  Faelle (leere/unparsebare Blobs, unbekannte Laender)
- LegacyAddressParser: Best-Effort-Strukturierung der Blobs (mehrzeilig,
  kommasepariert, CH-PLZ-Praefix, Firmen-/Zusatzzeilen); raet nie
- Laenderaufloesung ueber die country-Tabellen der Legacy-Verbindungen
- config/countries.php: CY ergaenzt (reale Bestandskunden aus Zypern)
- WS-7- und QA-Checkliste: Import-Schritt nach dem Grandfathering

Lokal: 11 von 21 Bestandskunden importiert, 9 im Report (7 leere Blobs);
MAN-Lauf rechnet zuvor blockierte Vereinbarungen jetzt ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:19:50 +00:00
387d41a782 Plan + QA-Checkliste: Legacy-Fixes vom 06.07. dokumentiert
Buchungs-Gate-Bug (grandfatheredNow) und Legacy-API-Uebernahme im
Stand-Block festgehalten; QA-Punkt zur API von "api_key wird abgewiesen"
auf die neue Kompatibilitaets-Semantik umgestellt (inkl. Import-Pruefung
beim Staging-Rehearsal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:59:04 +00:00
91992ac4b0 Uebernahme bestehender Legacy-API-Integrationen (Keys, Auth, Pfade)
Bestehende Kunden-Integrationen der Altportale laufen nach dem Relaunch
weiter, ohne dass Kunden etwas umstellen muessen:

1. legacy:import-api-keys uebernimmt die api_keys aus den Legacy-DBs
   (sf_guard_user_profile) als Sanctum-Tokens mit identischem Secret
   (Sanctum loest Tokens ohne id|-Praefix per sha256-Hash auf) — nur
   fuer berechtigte Nutzer (aktiver Zahlungsstatus/Bestandsschutz,
   ApiAccessEligibilityService); idempotent, mit Dry-Run. Probe gegen
   die lokalen Snapshots: 406 Keys wuerden uebernommen.
2. Die Auth-Middleware uebersetzt die alten Uebergabeformen (X-ApiKey-
   Header, api_key-Parameter) fuer gueltige Keys in Bearer; unbekannte
   Keys erhalten weiterhin 410 mit Migrationshinweis.
3. Die dokumentierten /service/...-Pfade der Alt-API sind als Aliasse
   auf die v1-Controller gelegt (pressreleases inkl. Bilder, companies
   lesend); nicht uebernommene Alt-Endpoints (Pressemappen/Kontakte
   anlegen) antworten 410 mit Hinweis auf Kundenkonto und neue Doku.

API-Seite + OpenAPI entsprechend aktualisiert; Go-Live-Checkliste um den
Import-Schritt (nach dem Grandfathering) ergaenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:55:22 +00:00
3cd7395319 Fix: Bestandsschutz ohne explizites Vertragsende zaehlte nie als aktiv
Der grandfatheredNow-Scope verlangte ein gesetztes grandfathered_until —
das ist aber das explizite Legacy-Vertragsende (valid_until_date) und im
Import-Regelfall NULL (offene Dauervereinbarung). Ergebnis: praktisch
kein Legacy-Kunde hatte eine aktive Zahlungsoption; eingeloggte
Bestandskunden sahen "Buchung erforderlich" und konnten nicht einreichen.
Jetzt gilt: grandfathered_until deckelt, wenn gesetzt; sonst zaehlt die
laufende, vom MAN-Rechnungslauf fortgerollte Periode (current_period_end
= next_due_date). Tests decken beide NULL-Faelle und den Deckel ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:40:39 +00:00
73f414e22a R6.1: QA-Checkliste erstellt, Vorpruefung abgeschlossen
dev/release/qa-checkliste.md loest Par. 15 der Scope-Doku ab: jeder
Punkt mit Beleg-Tests aus der gruenen Suite bzw. konkreten manuellen
Browser-/Staging-Schritten, inkl. Testzugaenge und Reihenfolge.
Befunde der Vorpruefung: /thumbnails-Redirect fehlte (separat gefixt,
d0ffe66); Newsletter-Double-Opt-In bewusst vertagt und als
Nicht-Blocker dokumentiert. Offen bleiben die manuellen Durchgaenge
(User) und die Staging-Punkte (R6.3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:16:21 +00:00
d754ef4bef QA-Luecke geschlossen: Legacy-Medien-URLs redirecten wieder (301)
Die Scope-Doku fuehrte den /thumbnails-Redirect als "ANGEPASST", im Code
existierte er nicht — alte Symfony-Bild-URLs (/thumbnails/..., /uploads/
...) liefen ins 404. Neuer LegacyMediaRedirectController mappt den
Original-Dateinamen (bleibt beim Medien-Import erhalten) auf die neue
Storage-URL fuer PM-Bilder und Firmenlogos; unbekannte Dateien bleiben
404. Gefunden bei der R6.1-Vorpruefung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:12:35 +00:00
90fd171e37 Depublikations-Preis festgelegt: 35 Euro netto (O6, User 06.07.)
Default 3500 Cent in config/billing.php (per Env ueberschreibbar,
0 deaktiviert) — Checkout und /melden-Preisanzeige (Doppelausweis
35,00 netto / 41,65 brutto) sind damit ohne weiteren Env-Handgriff
aktiv. O6 aus der Entscheidungsliste geschlossen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:57:11 +00:00
fac15f7f8e Plan: R3.1 komplett — Rechtstexte-System fertig (Suite: 956 gruen)
Restbacklog 06.07. dokumentiert: E3-Versionierung, rejection_categories,
T10/T12/T13. Offen nur noch [EXTERN] (anwaltliche Freigabe,
Depublikations-Preis, Postfach) und bewusste Phase-2-Punkte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:47:49 +00:00
198be381d6 T12: Depublikations-Checkout mit Tombstone (config-gated)
Kunden koennen eigene veroeffentlichte PMs kostenpflichtig aus der
oeffentlichen Ansicht nehmen: Panel auf der PM-Detailseite (inkl.
T1neu-Unternehmer-Checkbox und Doppelausweis netto/brutto), Stripe-
Ad-hoc-Checkout wie Credit-Topups, Erfuellung im Webhook — die PM
wechselt auf archived + depublished_at, die oeffentliche URL zeigt
dauerhaft einen Tombstone (410 Gone, ohne Inhalte, mit ehrlichem
Drittarchive-Hinweis). Doppelbuchungs-Bremse pro PM; Status-Log mit
Quelle "depublication". Gate: ohne BILLING_DEPUBLICATION_PRICE_CENTS
bleibt der Weg deaktiviert und /melden zeigt die neutrale
Preisformulierung [EXTERN: Preis festlegen, O6].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:46:25 +00:00
2a9be7a9af T10: DSA-Meldeformulare mit URL-Feld, Vorgangsnummer und Eingangsbestaetigung
Zwei eigenstaendige Formulare unter /melden/rechtswidrig (DSA Art. 16:
URL, Name, E-Mail, Begruendung, Wahrheitsversicherung) und
/melden/kontaktdaten (DSGVO-Antrag) auf allen drei Domains. Gemeldete
URLs werden automatisch der passenden veroeffentlichten PM zugeordnet;
ohne Treffer bleibt die URL im payload. Jede Meldung erhaelt eine
Vorgangsnummer (M-{id}, analog K-{id} der Kuendigung), die meldende
Person unverzueglich eine Eingangsbestaetigung (DSA Art. 16 Abs. 4) und
das Melde-Postfach einen internen Hinweis — auch im bestehenden
Per-PM-Flow. Admin-Queue zeigt Vorgangsnummer, Name und gemeldete URL;
/melden verlinkt die neuen Formulare als ersten Weg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:35:30 +00:00
61a677aa0b T13: Melde-Links sichtbar unter jeder Pressemitteilung
Unter dem Artikelinhalt steht jetzt eine eigene Zeile mit "Inhalt melden"
und "Eigene Daten entfernen" (inkl. Kostenlos-Hinweis) — bisher waren die
Wege nur in der Sidebar-Box "Weiterfuehrend" auffindbar. Erfuellt die
DSA-Anforderung eines leicht zugaenglichen Meldewegs direkt am Inhalt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:26:58 +00:00
0bec71ffa5 Ablehnungskategorien live aus der DB (rejection_categories)
/richtlinien rendert jetzt alle aktiven Kategorien aus der neuen
rejection_categories-Tabelle (stabiler code als Anker, public_label/desc,
optionale Beispiele) statt der statischen K1-K6-Liste. Kategorien werden
nie geloescht, nur deaktiviert; neue Kategorien erscheinen ohne
Textaenderung und ohne neue AGB-Version (AGB par. 7 Abs. 3 verweist nicht
abschliessend). Seeder legt K1-K6 idempotent an; Versionszeile der Seite
kommt aus legal_documents. Go-Live-Checkliste um beide Seeder ergaenzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:25:16 +00:00
7510e7633e E3: Rechtstext-Versionierung in der DB (legal_documents)
Jede veroeffentlichte Fassung eines Rechtstexts ist eine Zeile mit
type/version/effective_from — alte Fassungen sind unloeschbar, zukuenftige
werden erst ab Stichtag wirksam (LegalDocument::current). Seeder legt die
fuenf aktuellen Fassungen an (AGB 0.6, Datenschutz 0.3, Richtlinien 0.1,
Meldeverfahren 0.1, Impressum 0.1). Die Unternehmer-Bestaetigung (T1neu)
referenziert jetzt die konkrete Fassung (legal_document_id am
Buchungsdatensatz, restrictOnDelete); AGB-Seite und Buchungs-Modal ziehen
Version/Stand aus der DB. config/legal.php bleibt reiner Fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:21:52 +00:00
dc2b2b0d7d Plan: Klaerungsliste Rechtstexte komplett beantwortet (06.07.)
Alle inhaltlichen Freigaben erteilt (AGB-Portalliste via Hub-#familie,
Kuendigungsseiten-Ergaenzung, neutraler Depublikations-Preis, vorsichtige
Medienprivileg-Zitierung, Aenderungsweg ueber Magic-Links). Rechtstexte
damit inhaltlich final; offen nur noch anwaltliche Freigabe und das
Postfach info@pressekonto.com [EXTERN].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:02:32 +00:00
3940d462d9 Umami-Webanalyse eingebaut, bis zum Go-Live deaktiviert
Selbst gehostetes Umami (sys.adametz.media) je Brand: Website-IDs aus
dev/release/umami.md in config/domains.php, Snippet via x-web.umami im
web-master-Layout auf allen oeffentlichen Seiten der drei Domains.
UMAMI_ENABLED=false haelt es aus, bis die Webseiten live gehen
(User-Vorgabe 06.07.; Go-Live-Checkliste WS-7 ergaenzt). Damit stimmt
Datenschutz Abschnitt 5 mit dem Code ueberein. User bestaetigt ausserdem:
Log-/Backup-Fristen und Stripe-Entitaet passen wie eingetragen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:56:14 +00:00
43397adb6a Zentrale Rechts-E-Mail info@pressekonto.com fuer alle Rechtstexte
User-Entscheidung 06.07.: info@pressekonto.com ist die Haupt-E-Mail fuer
alle rechtlichen Anliegen. Zentral in config/legal.php (LEGAL_CONTACT_EMAIL),
verwendet von Impressum, AGB, Datenschutz, /melden und /richtlinien auf
allen drei Domains. Support-Kontakt bleibt je Brand (CONTACT_EMAIL_*),
REPORT_EMAIL_* ist nur noch optionales separates DSA-Postfach. Nebenbei
den verunglueckten Enterprise-Hinweis auf der Buchungsseite repariert
(E-Mail war ohne Trennung angeklebt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:32:59 +00:00
9d3eef60f8 Plan: Tagesabschluss 03.07. dokumentiert (Rechtstexte komplett, T1neu, Mail-CI)
Stand & Wiedereinstieg mit Klaerungsliste Rechtstexte, [EXTERN]-Punkten
und R3.1-Restbacklog aktualisiert. Nebenbei: Pint-Formatierung sidebar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:30:08 +00:00
9832869596 System-Mails im pressekonto-CI: zentrales E-Mail-Layout fuer alle Transaktionsmails
- Neues Layout components/emails/layout (Wortmarke, 'Publisher - Hub'-
  Tagline, Brand-Familien-Fusszeile) + Button-Komponente; alle System-
  Mails (Magic-Link, Go-Live-Reset, Kontakt, PM published/rejected,
  Kuendigung, Widerruf) darauf umgestellt
- Framework-Auth-Mails (Passwort-Reset, E-Mail-Verifizierung) via
  toMailUsing auf eigene Views im gleichen CI statt Laravel-Default
- Anmeldelink-Betreff vereinheitlicht; EmailBrandingTest sichert das
  Branding ueber alle Mails ab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:26:30 +00:00
f0cd11aa57 Plan: Datenschutz v0.3 + Cookie-Rueckbau + T1neu in R3.1 dokumentiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:18:56 +00:00
8916bcce7c T1neu: Unternehmer-Pflicht-Checkbox im Checkout, protokolliert mit AGB-Version
B2B-Gate (AGB v0.6 par. 1 Abs. 4): Das Buchungs-Modal verlangt vor jedem
Stripe-Checkout die Bestaetigung 'Ich bestelle in Ausuebung meiner
gewerblichen oder selbstaendigen beruflichen Taetigkeit (par. 14 BGB)'.

- booking_confirmations: user, Buchungstyp/-ref, AGB-Version, IP,
  confirmed_at (Nachweis laut Datenschutz Abschnitt 7)
- Checkout-Link erscheint erst nach der Checkbox; CheckoutController
  verlangt zusaetzlich serverseitig eine zeitnahe Bestaetigung (30 Min.)
  fuer Abo/Einzel-PM/Credit-Topup — Direktaufrufe laufen ins Leere
- config/legal.php als zentrale AGB-Versionsquelle (Seite + Protokoll,
  Uebergangsloesung bis zum legal_documents-System E3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:17:59 +00:00
99ab47ba1f Datenschutzerklaerung v0.3 eingebaut, Cookie-Seite zurueckgebaut
- Datenschutz: vollstaendige v0.3 aus docs/legal, host-aware Bausteine
  (Hub: Kundenkonto/Login-Magic-Link/Stripe; Spokes: Medienprivileg,
  Newsroom, Aenderungslink-Variante); Systemwerte aufgeloest (Magic-Link
  15 Min. aus MagicLinkGenerator, Logs 14 Tage, Stripe Payments Europe);
  ersetzt den Platzhalter samt falscher Google-Analytics-Behauptung
- Cookie-Seite raus (Cookies stehen in Abschnitt 4 der Datenschutz-
  erklaerung, kein Banner noetig): Route -> 301 auf /datenschutz,
  Legacy-Alias cookies.html -> datenschutz, Footer + Sitemap bereinigt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:17:59 +00:00
efe7f86b4e Plan: Hub-Inhalts-Audit + Rechtstexte-Einbau dokumentiert (E2 B2B-only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:55:07 +00:00
fc13038073 T2neu: Archiv-Hinweis vor Submit ersetzt Erloeschens-Checkbox (B2B, E2)
Die 356-BGB-Erlöschensbestätigung entfiel mit der B2B-Umstellung; an ihre
Stelle tritt der von AGB par. 13 Abs. 1 geforderte Hinweis auf die
dauerhafte Archivierung (Depublikation nur als kostenpflichtiger Service,
gesetzliche Ansprueche unberuehrt). Bestätigungszeitpunkt wird weiter
protokolliert (Spalte withdrawal_waiver_consented_at, historischer Name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:53:22 +00:00
545eb52cfe Preise nach Leistungsbeschreibung v0.2: B2B-Satz, Credit-Anker, Regeln
- Intro mit B2B-Erkennbarkeits-Satz (Gate-Flankierung, T14/T1neu)
- T9-Credit-Anker ('1 Credit entspricht rechnerisch 1 EUR netto,
  massgeblich sind die Buchungspreise') statt fixer Gleichung
- E5-Jahreszahlweise im Untertitel (nach 12 Monaten monatlich)
- Neue Hinweisbloecke: Slots/Kontingente, Credit-Gueltigkeit (3 Jahre +
  Erinnerung), Boost-Leistungszusage inkl. 60-Min-Regel, Partnerportale
  (derzeit keine Weitergabe) — Render-Pflichten, auf die die AGB verweisen
- T16-Wording: 'Inhaltsprüfung' statt 'Redaktionelle Prüfung'; nicht
  implementierte Benefits (Boost verguenstigt, Mengenrabatte,
  priorisierte Pruefung) aus dem PlanSeeder entfernt + DB nachgezogen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:53:08 +00:00
d577ff54b8 R3.1: Rechtstexte eingebaut — Impressum, AGB v0.6, Richtlinien, Meldeverfahren
- Impressum: echte Anbieterdaten (adametz.media, Bielefeld, USt-ID) statt
  erfundener GmbH; DDG statt TMG, MStV-Verantwortlicher, Verbund-Absatz,
  DSA-Kontaktpunkt (Art. 11/12) mit neuem REPORT_EMAIL_*-Melde-Postfach
- AGB: B2B-Fassung v0.6 (03.07.2026) vollstaendig gerendert (par. 1-16,
  interne Anwalts-Anmerkungen entfernt, Variablen je Host aufgeloest);
  ersetzt den widerspruechlichen Platzhalter (30-Tage-Frist etc.)
- Neu: /richtlinien (Rahmentext + Ablehnungskategorien K1-K6 mit Ankern,
  statisch bis zum rejection_categories-System) und /melden (DSA/DSGVO/
  Aenderung/Depublikation, verdrahtet mit den bestehenden Melde-Links an
  jeder Mitteilung; eigenstaendige Formulare = Backlog T10/T12)
- Footer beider Gerueste + Sitemap um Richtlinien/Melden ergaenzt
- Versioniertes legal_documents-System (E3) bleibt R3.1-Folgeausbau

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:53:06 +00:00
f3a95c854d API-Doku: echte Referenz statt Fantasie-Features
- /de/api komplett neu: reale Endpunkte, Felder, Token-Abilities,
  Rate-Limit und Statuscodes der API v1 (angelehnt an die Legacy-Doku
  von businessportal24.com/service/documentation.html); OAuth/SDKs/
  Webhooks/Integrations-Behauptungen und api.example.com entfernt
- docs/api/v1.yml (OpenAPI 3.1) erstellt — die docs.api.v1-Route lief
  bisher auf eine nie vorhandene Datei (500er aus Hub-Footer/FAQ)
- Token-Vergabe korrekt beschrieben: Selfservice im Konto mit
  Buchungs-Gate statt 'auf Anfrage'; X-ApiKey-Migrationshinweis
- ApiDocumentationTest entskippt + Seiten-Test ergaenzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:36:19 +00:00
0645d1d855 R3.2: Kontakt-/Ueber-uns-Routen zurueckgebaut, Spoke-Footer bereinigt
- GET kontakt/ueber-uns -> 301 auf die Ausgaben-Startseite;
  kontakt.store-POST bleibt fuer das Hub-Kontakt-Modal
- Legacy-Aliases (contact/kontakt/ueber-uns) buendeln auf die Startseite
- Spoke-Footer: Kontakt als mailto (Betreiber-Fallback bis R2.1),
  Ueber-uns-Link raus, Selfservice-Kuendigungslink ergaenzt
- Sitemap-Konstanten bereinigt, Tests angepasst

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:36:13 +00:00
73fc3827c8 Web-Inhalte auf echten Funktionsstand: FAQ, Hilfe, Veroeffentlichen
- FAQ komplett neu: B2B-Hinweis, echte Datei-Limits (Bilder 16 MB,
  Anhaenge inkl. Office/ZIP 25 MB), Video-Einbettungs-Behauptung raus,
  Stripe/Rechnung als Zahlungswege, Kuendigungs- und API-Antworten mit
  echten Zielen, kein Export-/Farben-/Wizard-Versprechen mehr
- Hilfe-Center: tote '#'-Links durch echte Ziele ersetzt (Registrieren,
  Kuendigungsseite, Konto-Login), Support-Kontakt als mailto, erfundene
  Geschaeftszeiten entfernt
- Veroeffentlichen: Fantasie-Preismodell ('ab 89 EUR', Credits je
  Veroeffentlichung, Cross-Publishing separat) durch echte Konditionen
  ersetzt (Einzel-PM + Tarife aus DB/config via VeroeffentlichenPageData);
  Magic-Link-Registrierungs-Claim korrigiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:35:26 +00:00
9c9f7f7144 Hub-Inhalte: Claims korrigiert + Widerrufs-UI zurueckgebaut (B2B-only, E2)
- Registrierungs-Schritt ehrlich beschrieben (E-Mail/Google, Freischaltung
  nach E-Mail-Bestaetigung statt 'direkt freigeschaltet'; Magic-Link ist
  nur Anmeldeweg)
- Newsroom-Karte: 'getrennte Berechtigungen'/'mehrere Redakteure' durch
  belegbare Aussagen ersetzt (keine Team-Verwaltung im Panel)
- Kuendigungs-FAQ nach E5 (Jahreszahlweise 12 Monate, danach monatlich)
  + Link auf die Selfservice-Kuendigungsseite
- B2B-Erkennbarkeits-Satz im Tarife-Intro (AGB v0.6 / T14)
- Wording T16: 'Inhaltsprüfung' statt Redakteurs-Versprechen; 'kein
  automatisierter Durchlauf'-Behauptung entfernt
- Widerrufs-Button + Modal-Includes entfernt (Entscheidung E2 B2B-only,
  T3neu: Kuendigungsbutton bleibt freiwillig); Backend/Route bleibt
  schlafend erhalten, reaktivierbar bei B2C-Oeffnung
- Hub-FAQ/Footer verlinken die API-Seite statt der toten docs-Route

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:35:03 +00:00
f46795ba77 Plan: R6.2 Security-Review + R6.6 Doku-Sync erledigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:44:36 +00:00
89550dac99 Security (R6.2): Haertung nach Audit
- Security-Header-Middleware (nosniff, X-Frame-Options, Referrer-/
  Permissions-Policy) global, proxy-unabhaengig; CSP bewusst separat.
- Firmenlogo-Upload im Create-Formular durch den ImageService-Pfad
  ersetzt (Mime-Whitelist ohne SVG, Re-Encode, UUID-Name) - vorher
  rohe store()-Ablage ohne Server-Validierung (SVG/XSS moeglich).
- Rate-Limits ergaenzt: Passwort-Reset (3/E-Mail + 15/IP pro Std.),
  Registrierung (10/IP pro Std.), throttle auf magic-login- und
  pm-vorschau-Consume-Routen (Defense-in-Depth).
- EnsureUserIsCustomer als persistente Livewire-Middleware
  (symmetrisch zum Admin-Gate, greift auch bei Folge-Requests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:43:59 +00:00
acb2a19aff Doku (R6.6): veraltete migration-2026-Dokus mit Verweis-Banner versehen
12-NAECHSTE-SCHRITTE und 08-PROGRESS zeigen klar auf den aktiven
Release-Plan; Phase 8/Stripe als erledigt markiert, damit die
eingefrorenen Staende nicht weiter in die Irre fuehren.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:34:56 +00:00
5537041014 Plan: R4.1/R4.2 Aufraeumen + R3.2 Rueckbau erledigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:23:40 +00:00
d34088e539 Web (R3.2): Marketing-Platzhalterseiten zurueckgebaut
team/partner/karriere/presse (erfundene Personen, Fake-Partner,
Bewerben-Buttons ohne Ziel) samt Routen entfernt (User-Entscheidung
03.07.: lieber weniger Seiten, aber ehrlich). Altes Scaffold
(livewire web.burger-menu/header/footer) wurde nur von diesen Seiten
genutzt und ist mit raus. Legacy-.html-Links: team/partner/karriere
-> 301 Startseite; presse/* behaelt die bestehende 301-Regel auf /faq.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:50 +00:00
2d1c7aa666 Web (R4): tote Alt-Frontend-Fragmente entfernt
7 ungeroutete Views (examples/, welcome, home 'B2IN', brand-mark-View,
web-master-test, altes engl. Header/Footer-Layout) + 12 ungenutzte
x-web-Komponenten + 7 nur von examples/ referenzierte Komponenten
(per grep verifiziert, User-Freigabe 03.07.). Verwaiste
.highlights-slider-CSS-Regeln mit entfernt; Web-Build gegengeprueft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:35 +00:00
f5bf282027 Deploy: .env.example vervollstaendigt + Deploy-Runbook (R5.1/R5.2)
Alle projektspezifischen Env-Keys dokumentiert (Multi-Domain, Legacy-DBs,
Stripe/Billing, KI, Sanctum, Newsletter, Vite); Runbook fuers neue
Laravel-System mit Prod-.env-Solltabelle, Release-Schritten,
Queue-Worker (classification), Cron-Tasks inkl. Ausfall-Folgen,
Stripe-Webhook, Erst-Einrichtung, Smoke-Test und Rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:38:24 +00:00
7494f19b4a Plan: R2.4 SEO-Basics erledigt (Sitemap, robots, Meta)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:34:43 +00:00
6e5d67c842 Web: Marketing-Seiten brand-aware (Titel/Meta) statt 'Business Portal 24'
karriere/partner/presse/team loesen die Brand jetzt pro Request ueber
WebPortalContext auf (config('app.theme') ist bei Route::view-Seiten
ohne Controller nicht gesetzt) und haben Meta-Descriptions;
businessportal24-Startseite auf das config-getriebene meta_title/
meta_description-Muster von presseecho vereinheitlicht.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:34:12 +00:00
e9643e620a SEO: host-aware sitemap.xml + dynamische robots.txt je Domain
Sitemap pro Host (1h-Cache): Hub listet Landing + Rechts-/Info-Seiten,
Spokes zusaetzlich Kategorien (lokalisierte Slugs), oeffentliche
Newsrooms und alle im Archiv sichtbaren PMs des Portals (explizit ohne
PortalScope, forPortalValues). Suche und Marketing-Platzhalterseiten
bleiben draussen. Statische public/robots.txt entfernt (haette die
Route verschattet); robots.txt jetzt dynamisch mit Sitemap-Verweis
und Disallow fuer /admin, /login, /register, Suche.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:34:12 +00:00
b855cbf7c0 Plan: Kuendigungsbutton (R3.0b) + Erloeschens-Checkbox (R3.0c) erledigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:42:17 +00:00
20d53988bd Recht: Erloeschensklausel-Checkbox im Publikationsflow (Par. 356 BGB)
Vierte Bestaetigung im Einreichungs-Modal (sofortige Ausfuehrung,
Erloeschen des Widerrufsrechts mit Vertragserfuellung); Zeitpunkt wird
beim Submit als withdrawal_waiver_consented_at an der PM protokolliert.
Index-Schnell-Submit (wire:confirm) durch das gemeinsame Modal ersetzt -
der Pfad umging bisher alle rechtlichen Bestaetigungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:41:31 +00:00
52bc534e7d Recht (Web): Vertraege-hier-kuendigen-Button + Bestaetigungsseite im Hub
Kuendigungsbutton (Par. 312k BGB) im Hub-Footer unter dem
Widerrufsbutton, staendig verfuegbar; fuehrt unmittelbar auf die
Bestaetigungsseite mit dem Formular und der gesetzeskonformen
Bestaetigungsschaltflaeche 'jetzt kuendigen'. Eigener Error-Bag,
damit das Kontakt-Modal im Footer nicht mit aufspringt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:32:53 +00:00
1c3a52ae3c Recht: Kuendigungsbutton nach Par. 312k BGB - Backend + Admin-Queue
Oeffentliche Bestaetigungsseite /de/kuendigung ohne Login: Art der
Kuendigung (Grund nur bei ausserordentlich), Zuordnungsdaten,
Beendigungszeitpunkt; Honeypot + Rate-Limit wie beim Widerruf.
Sofortige automatische Zugangsbestaetigung per E-Mail (Par. 312k
Abs. 4 BGB) + Betreiber-Mail. Kuendigungen laufen in der
Recht-&-Compliance-Queue unter den Widerrufen auf (K-Vorgaenge,
eigener Filter, Erledigt/Wieder-oeffnen, vierte KPI-Karte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:32:43 +00:00
239ec571bc Plan: Rechtstexte-Architektur referenziert + Hub-Geruest-Vorarbeit notiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:48:07 +00:00
258502bb85 Hub (Web): statische Seiten (Rechtstexte, API, FAQ ...) rendern das Hub-Geruest
x-web.static-page ist jetzt host-aware: Auf der pressekonto-Domain laden
Utility-Bar + Hub-Header/-Footer (inkl. Kontakt- und Widerrufs-Modal,
damit die Footer-Buttons ueberall funktionieren) statt des
Spoke-Geruests; Breadcrumb fuehrt zur Hub-Landing. Hub-Anker-Navigation
(#tarife/#funktion/#familie/#faq) absolut auf die Landing, damit sie
auch von Unterseiten aus funktioniert. Spokes unveraendert (Test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:47:47 +00:00
d6461f5c5e Admin: Widerrufe laufen in der Recht-&-Compliance-Queue auf
Eigene Widerrufe-Sektion unter der Anfragen-Tabelle in
/admin/legal-requests: Vorgangsnummer, Zuordnungsdaten, Statusfilter
(offen/alle/erledigt, eigener Paginator), Erledigt/Wieder-oeffnen-Aktion
mit resolved_at; KPI-Karte "Offene Widerrufe". Dazu
WithdrawalRequest-Factory (+resolved-State) fuer die Tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:29:32 +00:00
8001280185 Plan: Online-Widerruf (R3.0) erledigt + Follow-ups notiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:04:05 +00:00
515e249132 Recht (Web): Vertrag-widerrufen-Button + zweistufiges Widerrufs-Modal im Hub
Gut sichtbare Schaltflaeche "Vertrag widerrufen" im Footer; das Modal
fuehrt zweistufig durch den Widerruf: Schritt 1 nur Zuordnungsdaten,
Schritt 2 Kontrollansicht mit Bestaetigungsbutton "Jetzt widerrufen".
Erfolgs-/Fehlerzustand oeffnet automatisch (getrennter Error-Bag,
kollidiert nicht mit dem Kontakt-Modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:03:37 +00:00
8c0bf00604 Recht: Online-Widerruf ohne Login (gesetzlicher Widerrufsbutton) - Backend
POST /widerruf (host-agnostisch, throttled, Honeypot, eigener
withdrawal-Error-Bag): persistiert nur Zuordnungsdaten (Name, E-Mail,
optionale Bestellnummer - KEIN Grund, darf nicht verlangt werden) und
versendet sofort die gesetzlich vorgeschriebene automatische
Eingangsbestaetigung mit Zeitstempel an den Verbraucher plus eine
interne Benachrichtigung an die Brand-Kontaktadresse des Hosts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:03:37 +00:00
47d03ec777 Hub (Web): Auth-aware CTAs, Kontakt-Modal statt Mailto, Pruefung-Wording
- Angemeldete Nutzer sehen ueberall (Header, Hero, Kuendbar-Box, CTA,
  Footer) "Zum pressekonto" (rollengerecht via LoginRedirect::homeFor)
  statt Anmelden/Konto erstellen
- Keine E-Mail-Adresse mehr im Quelltext (Spam-Schutz): neues
  <x-web.hub.contact-modal> (Alpine, Honeypots, Erfolgs-/Fehlerzustand,
  auto-open nach Submit) am host-agnostischen kontakt.store-Endpunkt;
  Trigger in Enterprise-Streifen, FAQ-Support, DSGVO-Antwort, Footer
  und Utility-Bar
- "Redaktionelle Pruefung" -> "Pruefung" in allen Hub-Inhalten;
  "verifizierter Newsroom" -> "Ihr eigener Newsroom"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:52:29 +00:00
88d6935014 Kontakt: Hub-Domain-Anfragen gehen an die pressekonto-Brand-Adresse
Der WebPortalContext kennt nur die beiden Spokes - Anfragen von
pressekonto.test waeren sonst bei businessportal24 gelandet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:52:29 +00:00
38d7cca086 Plan: R2.2/R2.3 erledigt (Hub-Ueberarbeitung + Plan-Katalog-Pipeline verifiziert)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:24:39 +00:00
8d4b3b5ca3 R2.2/R2.3 (Web): Hub-Landing live-faehig - datengetriebene Tarife, ehrliche Inhalte
- Tarif-Grid aus Plan::active() (Preise/Kontingente/Limits/Vorteile wie
  Buchungsseite + admin/payments/plans), Alpine-Toggle monatlich/jaehrlich
- Fake-Katalog (0/49/149 Euro, Standard/Enterprise-Featureliste mit
  SSO/SLA/ISO-Claims) ersetzt; Enterprise = ehrliche Anfrage-Zeile
- Fake-Kundenliste (Siemens/BASF/...) und erfundene Kennzahlen raus;
  Plattform-Zahlen jetzt echt aus HubPageData
- Alle href="#" beseitigt: Footer-Rechtslinks auf /de/*-Seiten,
  Kontakt als mailto, Status/Doku/Changelog-Eintraege entfernt
- Unhaltbare Claims entschaerft (14-Tage-Test, 4h-Pruefzeit, sekundengenau,
  anteilige Erstattung, ISO 27001, RSS/Badge/CSV-Features)
- FAQ datengetrieben (Einzel-PM-Preis, ab-Preis aus Katalog, API-Doku-Link)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:24:04 +00:00
f6edb4aec5 R2.2: HubPageData - Plan-Katalog + echte Plattform-Kennzahlen fuer die Hub-Landing
Liefert Plan::active(), Einzel-PM-Preis und stuendlich gecachte echte
Zahlen (Archiv-Mitteilungen gesamt/je Portal, aktive Newsrooms,
Branchen) an web.pressekonto. Tests: Katalog-Preise statt Fake-Liste,
keine toten Links/erfundenen Referenzen, Rechtsseiten auf der
Hub-Domain erreichbar. (Gehoert zum Web-Commit der Hub-Ueberarbeitung.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:24:04 +00:00
5226f5de2b Plan: R2.1 erledigt + naechste Schritte notiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:15:25 +00:00
4e4aff115d Fix: legal-request-Honeypot nickt Bots wieder still ab
Die size:0-Regeln liefen vor dem Honeypot-Zweig: ein gefuelltes Feld
produzierte einen Validierungsfehler (verraet die Falle), der stille
done()-Pfad war tot. Honeypot-Check jetzt vor der Validierung; Test
schaerft das Soll (kein Fehler, normaler Erfolgs-Redirect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:14:52 +00:00
9875e7b6e1 R2.1 (Web): Kontaktformular auf der Kontakt-Seite aktiviert
Ersetzt den TODO-Platzhalter: Name/E-Mail/Nachricht, Honeypots,
Erfolgs-Flash statt Formular nach Versand, Datenschutz-Hinweis.
Styling analog legal-request. Telefon/Adresse sind weiterhin
Platzhalterdaten (R3.2, [EXTERN]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:14:52 +00:00
a4d054b3ab R2.1: Kontaktformular-Backend (Controller + Mailable + Brand-Empfaenger)
POST kontakt mit Validierung, Honeypot (vor der Validierung, stilles
Abnicken), IP-Rate-Limit (5/h) + throttle:10,1. Versand an
brand.contact_email (neue CONTACT_EMAIL_*-Keys, [EXTERN: echte
Adressen]), Fallback MAIL_FROM_ADDRESS; Reply-To = Absender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:14:52 +00:00
1e9c2f96ed Plan: R0 komplett + R1.1/R1.3 erledigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:08:45 +00:00
54a2e64698 R1.3: Quota-Reset als fester Bestandteil des Tarif-Modells festgeschrieben
Der "Stub bis zum Tarif-Modul"-Kommentar war ueberholt: Das Tarif-Modul
existiert und rechnet in pressReleaseQuotaRemaining() gegen genau diesen
Zaehler. Kalendermonats-Reset dokumentiert als bewusste Vereinfachung
(nicht an der individuellen Abo-Periode ausgerichtet). Keine Logik-
Aenderung; Verhalten via PressReleaseQuotaTest abgedeckt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:08:23 +00:00
ad2879daa4 R1.1: Buchungs-Gate geprueft + safe-by-default; Legacy-Buchungspfade festgeschrieben
Default von billing.enforce_booking auf true gedreht: ein in Produktion
vergessener Env-Key haette das Gate sonst still geoeffnet (jeder haette
ohne Zahlung veroeffentlichen koennen). Tests ergaenzt: Grandfathered
gueltig/abgelaufen/NULL (= abgelaufen, Entscheidung 19.06.), Active
ausserhalb der Periode, Service-Level-Submit mit Legacy-Buchung und
BookingRequiredException ohne Buchung. Scope-Logik selbst war korrekt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:07:01 +00:00
c3db80c651 R0.5: eigene Fehlerseiten 403/404/419/500/503 (self-contained, domain-aware)
Bisher griffen die Laravel-Default-Seiten. Gemeinsames Inline-CSS-Layout
ohne Vite-Abhaengigkeit (rendert auch bei kaputtem Asset-Build), nutzt
die geteilte domainName-Variable mit Fallback, noindex, Dark-Mode via
prefers-color-scheme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:01:29 +00:00
17eaa80135 R0.4: Seeder produktionssicher (PlanSeeder, keine Fake-Stripe-IDs, Dev-User-Guard)
- PlanSeeder (Starter/Business/Pro/Agency) haengt jetzt im DatabaseSeeder
- PaymentOptionSeeder erfindet keine prod_/price_placeholder-IDs mehr
  (Spalten nullable; echte Verknuepfung via Stripe-Sync/Pflege)
- Demo-Zugaenge admin@pressekonto.test/test@example.com (Passwort
  "password") werden nur noch in local/testing angelegt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:58:30 +00:00
f9e3c64238 R0.3: Newsletter-Sync-Binding config-aware + ehrliche Test-Sync-Meldung
Bisher wurde der NullNewsletterSyncClient unbedingt gebunden -
NEWSLETTER_SYNC_ENABLED=true mit echtem Provider haette stumm nichts
getan. Jetzt: Platzhalter-Provider -> No-Op-Client, aktivierter aber
nicht implementierter Provider -> RuntimeException (fail fast). Das
Admin-UI weist beim Test-Sync auf den Platzhalter hin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:55:35 +00:00
a4184af4fb R0.2: gdpr_consent_at bei allen Registrierungspfaden befuellen (DSGVO)
Feld existierte seit der Migration-2026-Erweiterung, wurde aber nirgends
geschrieben. Jetzt gesetzt bei: Volt-Registrierung (bestaetigte AGB-/
Datenschutz-Checkbox), Magic-Link-Provisioning und Google-OAuth (beide
via CustomerAccountProvisioner, Konto-Anlage nutzerinitiiert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:53:41 +00:00
1a612e04b2 R0.1: tote Dev-Varianten-Routen entfernt (fehlende Views -> 500 auf BP24)
/variant-1 + /variant-2 referenzierten nicht existierende Views
web.businessportal24-variant-float-glow/-glass-gradient; Fragmente der
alten Frontend-Version, nirgends verlinkt. Regressionstest ersetzt den
Stub in DevExampleRoutesTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:50:37 +00:00
cb2fe97609 Plan: Release-Entwicklungsplan (R0-R6) aus Vier-Achsen-Analyse konsolidiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:50:37 +00:00
db7c1dfbf1 Perf: customer press-releases-Liste ueber UNION statt OR-Paginate
Die Uebersicht /admin/me/press-releases war auch fuer leere Konten ~2s: der OR-Filter in scopeAccessibleBy zwang die Paginator-count(*) zum Full-Scan (EXPLAIN type=ALL, ~174k Zeilen) und den Zeilen-Fetch bei 0 Treffern zum vollen created_at-Index-Scan (~1s). Neue PressRelease::accessiblePageFor() nutzt dieselbe index-freundliche UNION wie accessibleStatusCountsFor(): schmale UNION liefert die Seiten-IDs ueber user_id-/company_id-Index, danach nur 25 Modelle hydratisiert; Total via schnellem UNION-count. Messung: leer ~2000ms -> 167ms, groesstes Konto (20.830 PMs) ~740ms -> 468ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:55:45 +00:00
90f3245e41 Plan: Phase 5 vollstaendig abgeschlossen (5.2/5.3/5.5 auf erledigt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:44:15 +00:00
349991a4e8 5.2: User-Formular-Feldraster in Blade-Components (role/billing) extrahiert
role- und billing-Feldgrids aus create+edit in <x-portal.user-role-fields> und <x-portal.user-billing-fields> gezogen (nur das innere Grid; Panel-/Card-Wrapper bleiben pro Seite). Nebenbei-Bugfix: create zeigt jetzt den billing.name-Validierungsfehler (Feld wurde via userBillingRules() schon validiert, Fehler aber nie gerendert). company-link-picker + contact-fields bleiben bewusst inline (divergiert bzw. nur in edit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:43:05 +00:00
55d0804917 5.3: PressReleaseStatus::badgeClass() zentralisiert + toten statusColor-Code entfernt
Status-Badge-Farblogik (ok/warn/err/muted) aus 7 divergenten match-Bloecken in eine Enum-Methode gezogen. Archiviert/Entwurf jetzt konsistent 'muted' (grau) auch auf Detail-/Edit-/Log-Seiten (vorher dort 'hub'-Akzent). Toter statusColor-Block (Flux-Farben, nie referenziert) in customer/show entfernt. flux:badge :color in admin/users bleibt bewusst separat (eigenes Farbvokabular, Einzel-Call-Site).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:36:15 +00:00
0e08d8acc4 5.5: Auth-Components abgeschlossen (funktional gegengetestet) + Plan-Stand
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:11:23 +00:00
1503d3a7ed Fix: rollenlose aktive Nutzer -> Backfill-Command + Import-Nachlauf
Problem: Ein eingeloggter Nutzer ohne admin/editor/customer-Rolle wird per
redirectUsersTo auf die Startseite geleitet und haengt dort fest. Ursache
sind Alt-Konten, deren Rollen-Zuweisung nie lief (frueher Import bzw. ein
nach dem User-Insert fehlgeschlagener Rollen-Sync).

- Neues Command users:backfill-portal-role (--role, --dry-run): weist aktiven,
  komplett rollenlosen Nutzern die customer-Rolle zu. api-only-Konten haben
  bereits eine Rolle und bleiben unangetastet; inaktive werden uebersprungen.
- legacy:import ruft den Backfill nach dem User-Schritt automatisch auf
  (self-heal, da updateOrCreate + Rollen-Sync nicht atomar sind).

Analyse-Ergebnis nebenbei: In der aktuellen DB war nur genau 1 echter Fall
(bereits gefixt); die zuvor genannten ~167 waren fast alle api-only (gewollt
ohne Portal-Zugriff). Command laeuft daher jetzt sauber auf 0.

Test: BackfillPortalRoleTest (customer-Zuweisung, Rollen-Erhalt bei
customer/api-only, Ueberspringen Inaktiver, dry-run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:23:56 +00:00
9fadfe6458 5.5: <x-password-input> + 6 Auth-Passwortfelder umgestellt
Self-contained Alpine-Toggle (Anzeigen/Verbergen) pro Feld; Props name/label/
autocomplete/placeholder/required/autofocus/show-error, id default auth-<name>,
Slot :labelAction fuer den 'Passwort vergessen?'-Link (login). Ersetzt die
field-pw-wrap-Bloecke in login, register (2), reset-password (2), confirm-
password (1). Die nun ungenutzten form-level x-data={showPassword...} entfernt.

Nebeneffekt/Bugfix: register+reset nutzten :placeholder="__(...)" auf rohem
<input> -> wurde als Alpine-Bind woertlich ausgegeben und schlug fehl (leerer
Platzhalter). Als Component-Prop wird __() korrekt in PHP aufgeloest, der
Hinweistext (z. B. 'Mindestens 8 Zeichen') erscheint jetzt.

Bestaetigungsfelder ohne Fehleranzeige (show-error=false, wie zuvor).
116 Auth-Tests gruen, alle Views kompilieren.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:04:26 +00:00
1729e35943 5.2: gemeinsamen Firmen-Sync-Kern als User::syncCompaniesWithRoles() gezogen
Analyse der beiden ~40/160-Zeilen-save()-Methoden (users/create vs edit): sie
teilen nur den User-Attribut-Payload und den byte-identischen Firmen-Sync-Loop
(Bearbeiten hat zusaetzlich Profil/Kontakte/volle Billing-CRUD). Ein voller
UserUpsertService waere Over-Engineering (ueberwiegend Branches) — daher nur
der wirklich gemeinsame Persistenz-Kern:

User::syncCompaniesWithRoles(array $companyIds, array $roles) baut das
Pivot-Payload (fehlende Rolle => 'member') und synct. Beide Formulare nutzen
es; im Bearbeiten kommen die linkedCompanyIds nun direkt aus $validated.

Test: UserSyncCompaniesTest (Rollen-Zuordnung, member-Default, Detach).
29 User-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:47:23 +00:00
6b1d84fc01 Doku: 5.4 abgeschlossen + 5.5 Teil-Stand im Entwicklungsplan
5.4 als erledigt markiert (page-header/empty-state/table-pagination; table-
panel + searchable-select bewusst nicht gebaut). 5.5 Teil-erledigt (alert +
session-status); password-input + rate-limit-trait mit Begruendung zurueck-
gestellt. Wiedereinstieg-Abschnitt + Datum aktualisiert, Perf-Fix vermerkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:44:44 +00:00
b1b832df4c 5.5: <x-session-status> + 4 Auth-Seiten umgestellt
Self-conditional Component fuer den session('status')-Block (field-status +
role=status); Margin per class. Ersetzt den identischen 5-Zeilen-Block in
login, forgot-password, register, magic-link.

Bewusst NICHT umgestellt: verify-email (fixe Meldung statt session('status'),
Bedingung ===verification-link-sent, mb-6) und settings/profile (eigener
Kontext) — bleiben unveraendert. Alle Views kompilieren, 95 Auth-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:41:46 +00:00
587d1fd312 5.5: <x-portal.alert> + 6 Flash-Bloecke umgestellt
Erste 5.5-Component: buendelt den kopierten session()-Flash-Alert (Icon +
Farbschema) mit variant=success|error; Nachricht als Slot. Erzeugt
identisches Markup.

Umgestellt: categories/create (success), categories/edit (success+error),
footer-codes/index (success+error), presets/index (success).

Bewusst NICHT umgestellt: companies/show (abweichendes, icon-loses Design
mit zusaetzlicher info-Variante und anderen Farbtokens) — bleibt bespoke.

Render-Smoke geprueft, alle Views kompilieren, 23 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:39:44 +00:00
505852f29e 5.4: <x-portal.table-pagination> + 13 Wrapper ersetzt
Buendelt den 13-fach byte-identischen Paginierungs-Fuss (border-t + p-4 um
->links('components.portal.pagination')) in eine Component mit :paginator-
Prop. Erzeugt identisches HTML -> kein visuelles Risiko.

Ersetzt in: users, invoices/index, payments/index (4x: Bestandsschutz,
Subscriptions, Purchases, Invoices), contacts/index, legal-requests/index,
presets/index, companies/index, footer-codes/index, customer/invoices,
customer/press-releases/index.

Bewusst BARE gelassen (kein Wrapper im Original): press-releases/index,
categories/index, press-kits/index. Alle Views kompilieren, 251 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:35:37 +00:00
1c75e61179 5.4: empty-state auf customer/press-releases + customer/press-kits ausgerollt
Component um optionalen Default-Slot erweitert (rendert unter den Aktionen)
fuer Zusatzinhalt wie die Onboarding-Schritte der Leerzustaende.

Umgestellt:
- customer/press-releases/index: 3 States (Suche ohne Treffer, Filter ohne
  Treffer=warm, gar keine PMs inkl. Onboarding-Grid im Default-Slot)
- customer/press-kits/index: 2 States (Filter=warm, keine Firma inkl.
  Onboarding-Grid), Panel-Wrapper bleibt erhalten

Der Such-Titel behaelt das originale ASCII-Anfuehrungszeichen (via einfach-
gequotetem Attribut mit escaptem PHP-String). Alle Views kompilieren,
162 Customer-/PM-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:06:21 +00:00
077d313baa 5.4: <x-portal.empty-state> + admin/press-releases/index umgestellt
Zweite Struktur-Component: buendelt den empty-stage-Block (runder Icon-Rahmen
+ Titel + Subtext + optionale Aktionen). Props: title, subtitle, variant
(default|warm|err); Slots :icon und :actions.

Erst-Umstellung: die zwei Leerzustaende der Admin-PM-Liste (gefiltert = warm
mit 'Filter zuruecksetzen', leer = Standard mit 'Neue PM anlegen'). Weitere
Vorkommen (customer/press-releases, customer/press-kits) folgen nach Sicht-
pruefung. 36 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:05:19 +00:00
feda8be4d3 5.4 (Teil 6): page-header in payments/invoices/legal/coupons/reports/newsletter
Component minimal-additiv erweitert (Defaults unveraendert):
- size=sm (28px) fuer legal-requests/show
- description-width=720px fuer breite Fliesstexte

Umgestellt (letzter grosser Batch):
- payments/index, payments/plans (720px, Aktionen)
- invoices/index, coupons/index (720px, Badge via :meta, keine Aktionen)
- legal-requests/index (Standard), legal-requests/show (sm, Status-Badge)
- reports/slow-requests (Logdatei-Zaehler-Badge als Aktion)
- newsletter/sync (Sync-Status via :meta, Dry-Run/Test-Sync-Buttons)

CSS-Hash unveraendert -> keine neuen Klassen. 74 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:36:33 +00:00
27a77bfc50 5.4 (Teil 5): page-header in roles/* + footer-codes/* + presets/*
Neun weitere Standard-Header (md) auf <x-portal.page-header>:
- roles/index (bedingter Anlegen-Button), roles/create (Guard-Badge via
  :meta), roles/edit (ID + Guard + bedingte Systemrolle via :meta)
- footer-codes/index, footer-codes/create, footer-codes/edit (ID + Global +
  Aktiv/Inaktiv via :meta, Titel als Beschreibung)
- presets/index, presets/create, presets/edit (ID via :meta)

Roles-/FooterCode-/Preset-Tests (23) gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:01:47 +00:00
1eb2243959 5.4 (Teil 4): page-header size=lg-Variante + press-releases/* + companies/index
Component um size-Prop erweitert: md (30px, Standard) | lg (34px, Landing-
Header). lg vergroessert zusaetzlich Beschreibung (13.5px) und Button-Abstand
(gap-3) — exakt die bisherige Sonderoptik der Listen-Landing-Header.

Umgestellt:
- press-releases/create (md), press-releases/edit (md, Status-Badge + ID via
  :meta, 3 Aktionen inkl. Modal-Trigger)
- press-releases/index (lg) und companies/index (lg) — Letzterer war zuvor
  bewusst ausgelassen, ist mit der lg-Variante jetzt originalgetreu abbildbar.

Bewusst NICHT umgestellt: press-releases/show (zusaetzlicher Untertitel +
mehrteilige Firma/Kategorie/Autor-Beschreibung, bleibt bespoke).

md-Ausgabe unveraendert (nur Klassenreihenfolge). 321 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:16:40 +00:00
2a3b5612cb 5.4 (Teil 3): page-header in users/* + contacts/* umgestellt
Sieben weitere Header auf <x-portal.page-header>:
- users (index), users/create, contacts/index, contacts/create (Standard;
  contacts/create mit bedingtem 'Firma vorausgewaehlt'-Badge via :meta)
- users/show, users/edit (ID + Portal + Aktiv/Inaktiv-Badges via :meta,
  dynamischer Titel/Beschreibung aus User-Daten)
- contacts/edit (ID + Portal-Label via :meta, ohne Beschreibung)

Component: break-words am H1 ergaenzt (fuer lange Namen/E-Mails in show/edit;
bei kurzen Titeln ein No-op). User-/Contact-Tests (129) gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:08:06 +00:00
f3a9c423dc Fix: fehlendes .search-wrap-Styling (Suchfeld in /admin/me/firmen unsichtbar)
Die Firmen-Toolbar nutzt .search-wrap fuer das Suchfeld, aber die Klasse war
in keinem CSS definiert -> das Feld hatte weder Hintergrund noch Border und
war kaum erkennbar. Styling ergaenzt (weisser Card-Hintergrund #ffffff,
gleiche Border/Radius wie .filter-chip, focus-within-Highlight), damit es
sich konsistent in die Filterleiste einfuegt. Dark-Mode ueber die Tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:49:19 +00:00
af15e84984 5.4 (Teil 2): page-header in categories/* + companies/create,edit umgestellt
Vier weitere Standard-Header auf <x-portal.page-header> umgestellt:
categories/index, categories/edit (ID/PM/Unterkategorien-Badges via :meta),
companies/create, companies/edit (ID-Badge via :meta).

Bewusst NICHT umgestellt (Sonderfaelle, bleiben unveraendert):
- companies/index: abweichende Titelgroesse (text-[34px] statt 30px)
- companies/show: Sonder-Header mit Logo-Bild + Website-Link

Categories-/Companies-Tests (82) gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 08:31:45 +00:00
83bb2a7caa 5.4 (Teil 1): <x-portal.page-header> + categories/create umgestellt
Erste Seiten-Struktur-Component fuer das Admin-Backend: buendelt den 35-fach
kopierten page-header-Block (Hub-Badge + Eyebrow + H1 + Beschreibung, dazu
Slots :meta und :actions fuer die variablen Teile). Muster wie <x-portal.
stat-card>.

Bewusst nur EINE Seite (categories/create) umgestellt, damit die Optik
einzeln im Browser gegengeprueft werden kann, bevor die restlichen 34 Header
folgen. AdminCategoryManagement-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:00:09 +00:00
580c918ad3 Perf: Status-Counts der PM-Uebersicht via UNION statt OR-GROUP-BY
Die nach Status gruppierte Zaehlung lief ueber accessibleBy() (user_id OR
company_id IN sub). Der OR-Ausdruck zwingt MySQL zu Full-Scans (EXPLAIN:
companies type=ALL, press_releases Full-Index-Scan ueber 124k Zeilen) und
war mit ~500-950ms der groesste Einzelposten der Seite.

Neue PressRelease::accessibleStatusCountsFor(User, ?Closure): kombiniert
Autor- und Firmen-Zweig als indexfreundliche UNION-Subquery (per PK
dedupliziert) und gruppiert darueber. Im Benchmark ~2,5x schneller bei
identischer Verteilung. Der Firmen-Filter der Uebersicht wird als geteilte
Closure auf Counts UND Liste angewandt (garantiert deckungsgleiche Filterung).

Gesamtmessung /admin/me/press-releases (User 14016, 26.772 Firmen / 20.830
PMs): Query-Pfad ~2850ms -> ~740ms warm; hydratisierte Company-Models 24.

Tests: accessibleStatusCountsFor zaehlt Autor+Firma je einmal (UNION-Dedup)
und wendet Zusatz-Constraints auf beide Zweige an.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:55:13 +00:00
07d98ed79e Perf: accessibleBy() nutzt company_id-Subquery statt materialisiertem whereIn
scopeAccessibleBy() haengte den Firmenzugriff bisher als whereIn() mit allen
via accessibleCompanyIds() materialisierten Firmen-IDs an. Bei Power-Usern
(26k+ Firmen) ist das ein riesiger IN-Block im SQL.

Jetzt: orWhereIn('company_id', Company::accessibleBy($user)->select('id')) —
MySQL loest die Subquery als Semi-Join ueber den company_id-Index. Gleiche
Semantik (Company-Global-Scopes werden mitkompiliert), aber beschraenkte
SQL-Groesse und keine PHP-seitige ID-Liste mehr.

Benchmark (User 14016): ein korreliertes orWhereHas-EXISTS war ~3x langsamer
(1070ms vs 335ms COUNT) — die Subquery-whereIn-Form ist die schnellste.
accessibleCompanyIds() bleibt fuer canAccessCompany() erhalten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:30:49 +00:00
ed1aa07ae6 Perf-Fix: User::accessibleCompanyIds() hydratisiert keine Company-Models mehr
Auf /admin/me/press-releases wurden bei einem User mit sehr vielen Firmen
~107.000 Company-Models geladen. Ursache: accessibleCompanyIds() nutzte
das Eloquent-Relation-pluck() (ownedCompanies()->pluck() / companies()->
pluck()), das pro ID ein vollstaendiges Model hydratisiert. Die Methode
wird ueber PressRelease::scopeAccessibleBy() pro Seitenaufruf ~2x
ausgewertet (Status-Count- + Listen-Query) -> ~2x 53k Models.

Fix:
- toBase()->pluck() statt Relation-pluck(): liefert nur die ID-Werte ohne
  Hydration, wendet die Global Scopes (PortalScope) weiterhin an.
- Ergebnis pro Request memoisiert (cachedAccessibleCompanyIds), da der Scope
  die Methode mehrfach aufruft.

Messung (User 14016, 26.772 Firmen): 107.238 -> 24 hydratisierte
Company-Models auf der PM-Liste. Ergebnis-IDs unveraendert.

Regressionstests: accessibleCompanyIds liefert owned+member ohne Dubletten
und hydratisiert 0 Company-Models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:18:51 +00:00
c16b0b77e2 Doku: portalBadgeColor()-Klaerung im Plan vermerkt (toter Code entfernt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:57:19 +00:00
055fd5c47f Optimierung 5.3: toten portalBadgeColor()-Helfer entfernt
In companies/index, companies/show und contacts/index war die private
Methode portalBadgeColor() definiert, aber nirgends aufgerufen (kein
Aufruf im Blade-Markup oder PHP). Entfernt; in companies/show wird damit
auch der Portal-Import ungenutzt und faellt weg.

Klaert den im Wiedereinstieg-Plan offenen Punkt: keine Portal::badgeColor()-
Zentralisierung noetig, da es keine echten Call-Sites gab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:56:50 +00:00
63071a0a3e Doku: Stand & Wiedereinstieg-Abschnitt im Entwicklungsplan
Klarer Status (Phasen 0-4 + 5.1 fertig, 5.2/5.3 teilweise) und konkrete
Reihenfolge fuer den Wiedereinstieg in die verbleibende Blade-Component-Arbeit
(visuelle QA noetig, eine Component pro Commit). Hinweise zu ausgeschlossenen
User-Dateien (sidebar, web/**) festgehalten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:25:22 +00:00
dc68404891 Doku: Phase-5.3-Fortschritt (displayName/localizedName erledigt, Rest UI-lastig)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:20:33 +00:00
40679119c3 Optimierung 5.3 (Teil 2): Category::localizedName()
Name aus der locale-gefiltert geladenen Uebersetzung mit #id-Fallback als
Model-Methode. Ersetzt die identische Inline-Logik an 4 Stellen (Footer-Codes
und Kategorien jeweils create/edit).

Test: CategoryLocalizedNameTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:19:06 +00:00
69395efa53 Optimierung 5.3 (Teil 1): Contact::displayName()
Anzeigename eines Kontakts (Vor-/Nachname mit Fallback) als Model-Methode.
Ersetzt die identische Inline-Logik an 6 eindeutigen Relation-$contact-Stellen
(Admin: Benutzer-/PM-/Firmen-Detail; Kunde: Press-Kit-/PM-Detail).

Bewusst noch offen: Select-/toBase-Varianten ($contactOption/$contactResult/
$lookupContact, stdClass-Risiko) sowie web/release-detail (User-Frontend).

Test: ContactDisplayNameTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:15:38 +00:00
89842ac37f Optimierung 5.2 (Teil 1): ProvidesUserFormRules-Concern
Die zwischen Benutzer-Anlegen und -Bearbeiten identischen Validierungsbloecke
liegen jetzt in ProvidesUserFormRules: userIdentityRules(?int $ignore) (E-Mail-
Unique ignoriert beim Bearbeiten den eigenen Datensatz), userRolesAndCompaniesRules()
und userBillingRules(). Beide Formulare nutzen die Array-Spreads; die nur im
Bearbeiten genutzten Profil-/Kontakt-Regeln bleiben dort inline. Ungenutzten
Rule-Import aus dem Anlegen-Formular entfernt.

Offen (mit User abzustimmen, UI-/Divergenz-lastig): gemeinsamer Save-Service und
die Blade-Feld-Components.

31 User-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:41:57 +00:00
a8958c176e Optimierung 5.1: Tabellen-Concerns (Sortierung, Volltext-Heuristik, Stats)
- HasSortableTable::sort() ersetzt 5 byte-identische sort()-Kopien in den
  Admin-Listen (Benutzer, Firmen, Kategorien, Kontakte, Pressemitteilungen).
  $sortBy/$sortDir-Defaults bleiben je Komponente, da sie divergieren.
- HasFullTextSearch::supportsFullTextSearch() ersetzt 9 byte-identische Kopien
  (4 Index-Listen + 5 Firmen-Comboboxen); ungenutzte DB-Imports entfernt.
- AdminPerformanceCache::activeInactiveStats(Builder) buendelt den identischen
  SUM(CASE WHEN is_active)-Block der Benutzer- und Firmenliste.

Zurueckgestellt (real divergent): HasResettableFilters (updatedXxxFilter
normalisieren teils zusaetzlich) und eine gemeinsame hydrateRelationCounts().

Tests: erweiterte AdminPerformanceCacheTest; 99 Admin-Tests bleiben gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:36:48 +00:00
8e0aedaf95 Doku: Phase 4 im Entwicklungsplan abgeschlossen markiert (4.1-4.4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:16:13 +00:00
43c83ca32d Optimierung 4.3/4.4 (Home): Page-Data-Service + Invokable Controller
Startseiten-Datenaufbereitung ($webHomeData) in HomePageData; die Edition-Home-
Route nutzt Web\HomeController (pressekonto-Redirect + Portal->View-Mapping).
Damit ist routes/web.php von ~1080 auf ~230 Zeilen geschrumpft; alle
datengetriebenen Seiten laufen jetzt ueber schlanke Invokable Controller +
Page-Data-Services. Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:14:38 +00:00
e879a38ee4 Optimierung 4.3/4.4 (Release-Detail): Page-Data-Service + Invokable Controller
Detailseite einer Pressemitteilung (inkl. Hit-Zaehler) in ReleaseDetailPageData
+ Web\ReleaseDetailController. Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:35 +00:00
ee74126b89 Optimierung 4.3/4.4 (Kategorie): Page-Data-Service + Invokable Controller
Kategorie-/Branchenseite in CategoryPageData + Web\CategoryController.
Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:09:58 +00:00
179dd8e004 Optimierung 4.3/4.4 (Newsroom-Detail): Page-Data-Service + Invokable Controller
Newsroom-Markenseite in NewsroomDetailPageData + Web\NewsroomDetailController.
Controller-Signatur fuehrt {edition} vor {slug} (positionsgebundene
Route-Parameter-Aufloesung). Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:08:21 +00:00
a2179ea47f Optimierung 4.3/4.4 (Newsrooms): Page-Data-Service + Invokable Controller
Newsrooms-Verzeichnis-Closure in NewsroomsPageData + Web\NewsroomsController
verschoben. Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:04:59 +00:00
fef6bbc9a6 Optimierung 4.3/4.4 (Veroeffentlichen): Page-Data-Service + Invokable Controller
Veroeffentlichen-Closure in VeroeffentlichenPageData + Web\VeroeffentlichenController
verschoben. Verhalten unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:03:34 +00:00
418a2bca4c Optimierung 4.3/4.4 (Suche): Page-Data-Service + Invokable Controller
Suche-Closure in SuchePageData + Web\SucheController verschoben. Verhalten
unveraendert, 92 Web-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:01:18 +00:00
7625ec7ed5 Optimierung 4.3/4.4 (Preise): Page-Data-Service + Invokable Controller
Erste Seite des Web-Routen-Umbaus: die Preise-Closure aus routes/web.php in
PreisePageData (Datenaufbereitung) + Web\PreiseController (schlanker Invokable
Controller) verschoben. Route nutzt jetzt die Controller-Klasse. Verhalten
unveraendert, Web-Tests bleiben gruen (92).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:59:59 +00:00
e5a63bcedd Optimierung 4.1: Web-Portal-/Domain-Kontext extrahiert
- WebPortalContext::fromRequest() buendelt die zuvor ~6x je Route-Closure
  wiederholte Host->Portal-Ableitung (portal, language, portalValues,
  domainConfig).
- DomainAssetContext::applyWebDomainConfig() ist die Single Source of Truth fuer
  die Domain-/Theme-Anwendung (config + View::share + Vite); die fruehere
  $applyWebDomainConfig-Closure in routes/web.php ist nur noch ein duenner
  Delegations-Wrapper, damit Bare-Root- und Edition-Home-Route unveraendert
  per Closure aufrufen koennen.
- Die 6 Portal-Seiten-Closures (Kategorie, Detail, Veroeffentlichen, Suche,
  Newsrooms, Newsroom-Detail) sowie Preise nutzen jetzt den zentralen Kontext.

Abgesichert durch die 92 Web/-Tests (jede Seite laeuft ueber den neuen Kontext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:42:00 +00:00
987a5a2c42 Optimierung 4.2: Published-Release-Query ueber PressRelease-Scopes zentralisiert
Vier komponierbare Scopes auf PressRelease:
- published()         Status freigegeben + published_at gesetzt (ohne Zeitschranke,
                      damit Statistik-Fenster frei einschraenken koennen)
- visibleInArchive()  published() + published_at <= jetzt (oeffentlich sichtbar)
- forPortalValues()   whereIn portal
- forLanguage()       where language

~19 duplizierte Query-Bloecke in routes/web.php (Home, Kategorie, Detail,
Veroeffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) nutzen
jetzt diese Scopes. Jedes withoutGlobalScope(PortalScope) bleibt an seiner
Stelle erhalten; die Klausel-Reihenfolge ist fuers Ergebnis irrelevant (reine
AND-Verknuepfung), daher verhaltensgleich.

Test: Web/PublishedReleaseScopeTest; die 89 bestehenden Web/-Tests bleiben gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:55 +00:00
4c80b934ab Doku: Phase 3 im Entwicklungsplan abgeschlossen markiert (3.1-3.3 nachgezogen)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:21:22 +00:00
31e01e46b6 Optimierung 3.6: Zugriffs-Gates und getrennte Impersonation-Sperre
- Gate::define('access-admin'|'access-customer') als Single Source of Truth im
  AppServiceProvider (auch via @can/Policies nutzbar). EnsureUserIsAdmin und
  EnsureUserIsCustomer pruefen jetzt ueber diese Gates.
- Impersonation-Redirect aus EnsureUserIsAdmin in die eigene Middleware
  BlockAdminDuringImpersonation ausgelagert (Sitzungs-Bedingung statt
  Rollenfrage); laeuft vor der Zugriffspruefung und ist zusaetzlich als
  persistente Livewire-Middleware registriert.
- Routen-Alias 'can:' bewusst NICHT verwendet: die Admin-Middleware ist
  persistente Livewire-Middleware und braucht eine konkrete Klasse; der
  parametrisierte Alias kann dort nicht stehen. EnsureApiUserIsActive bleibt
  eigenstaendig (is_active-Pruefung mit getesteter JSON-Antwort, kein Gate).
- Tests: Auth/AccessGatesTest; UserImpersonationTest bleibt gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:20:28 +00:00
b255d90721 Optimierung 3.5: Kleinere Zentralisierungen (VAT, findByEmail, Waehrung, Provisioning)
- VatResolver::normalizeCountryCode()/isEuCountry() als Single Source of Truth;
  resolve(), VatIdValidationService und StripeCheckoutService nutzen jetzt die
  zentrale EU-Pruefung statt eigener config-Lookups.
- User::findByEmail() buendelt die LOWER(email)-Lookups (ContactAccessService,
  SocialAuthService). Bleibt bewusst case-insensitiv, da E-Mails nicht
  normalisiert gespeichert werden; der indexfreundliche Direktvergleich
  braucht erst eine Schreib-Normalisierung (offen, dokumentiert).
- config('billing.default_currency') ersetzt 8 verstreute 'EUR'/'eur'-Literale
  (Stripe-Calls via strtolower). Vereinheitlicht nebenbei die zuvor uneinheitlich
  gespeicherte SinglePurchase.currency.
- Neuer CustomerAccountProvisioner::provisionVerifiedCustomer() ist die
  gemeinsame Provisioning-Methode (verifiziert + aktiv + customer-Rolle) fuer
  ContactAccessService und SocialAuthService.
- Tests: UserFindByEmailTest, erweiterte VatResolverTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:14:05 +00:00
31d407d3ad Optimierung 3.4: Performance (Logging terminable, Single-Purchase memoisiert)
- LogApiUsage als terminable Middleware: der ApiUsageLog-INSERT laeuft nach
  gesendeter Antwort und verlaengert nicht mehr die Request-Dauer. Startzeit
  wird ueber ein Request-Attribut uebergeben (terminate() laeuft auf frischer
  Instanz).
- User::openSinglePurchaseCount() memoisiert die offenen Einmalkaeufe pro
  Request. Vorher wurde derselbe grantingSubmission-Count im Status-Widget
  mehrfach (Quota-Total, Quota-Remaining, openSinglePurchases) plus ein
  exists() in hasActiveBooking abgesetzt -> jetzt eine Query.
- CustomerCompanyContext::selectedCompany() nutzt eine einzige first()-Query
  statt exists()+first(); das Verwerfen nicht (mehr) erreichbarer Auswahlen
  bleibt erhalten.
- LegacySubscriptionService::cancelBundleFor() ist ein einziges Bulk-Update
  statt Update-pro-Iteration (UserPaymentOption hat keine Observer/Events).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:05:07 +00:00
9a93903cd9 Optimierung 3.3: totes SetDomainUrl entfernt
SetDomainUrl war nirgends registriert und duplizierte die URL-Forcing-
Logik (forceRootUrl/forceScheme/useAssetOrigin/config) aus
ThemeServiceProvider::boot(). Ersatzlos geloescht. Die Host->Config-
Aufloesung ist bereits zentral in DomainAssetContext::resolve gebuendelt
(ThemeMiddleware wurde in 0.4 entfernt, SetCurrentPortal nutzt seit 0.4
Portal::fromTheme).

Bewusst NICHT angefasst: CurrentPortalContext (mutable static) ->
request-scoped Binding - Tests koppeln an ::clear(), Umbau beruehrt viele
Call-Sites (eigener, riskanter Schritt fuer spaeter).

Tests: volle Suite gruen (802 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:31:23 +00:00
4fc46b02bf Optimierung 3.2: API-PR-Ownership-Scope + zentrale Eager-Loads
- PressRelease::scopeOwnedByApiUser() (bewusst eng: nur user_id-Autorschaft,
  anders als das breitere scopeAccessibleBy) ersetzt die byte-gleiche
  findOwnedPressRelease-Query in PressReleaseController + ImageController.
- apiResourceRelations()-Helfer ersetzt den 5x identischen Eager-Load-Set
  (company/category.translations/images) in Index/Show/Create/Update/Reclassify.

Bewusst NICHT umgesetzt: Slug-Reimplementierung (uniqueSlug) durch
HasUniqueSlug ersetzen - der API-Fallback Str::random(8) weicht vom
Trait-slugFallback() ab (Verhaltensaenderung bei leer-slugifizierten Titeln).

Tests: neuer Scope-Test; volle Suite gruen (802 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:27:59 +00:00
dba76910ca Optimierung 3.1: Company::scopeAccessibleBy() zentralisiert Firmenzugriff
Die 4x identische Zugriffs-Closure (owner_user_id == me OR whereHas('users',
me)) in CompanyController (index/show), PressReleaseController::
findOwnedCompany und CustomerCompanyContext::accessibleCompanyQuery durch
einen Company::scopeAccessibleBy() ersetzt; das Scope-Verhalten
(withoutGlobalScopes) bleibt beim Aufrufer.

Bewusst unangetastet: User::accessibleCompanyIds() (relationenbasiert MIT
globalen Scopes - anderes Scope-Verhalten) und PressReleaseImageController
(prueft PR-Autorschaft user_id, nicht Firmenzugriff).

Tests: neuer Scope-Test; volle Suite gruen (801 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:23:16 +00:00
6a1240bdfb Doku: Phase 2 im Entwicklungsplan als abgeschlossen markiert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:17:28 +00:00
153bac7a9c Optimierung 2.8: <x-portal.pr-presubmit-check> Blade-Component
Den in allen vier PM-Editoren identischen, rein praesentationalen
Pre-Submit-Checklisten-Block (~34 Zeilen je Datei) in eine
Blade-Component ausgelagert, die $this->presubmitChecks als Prop nimmt.
Render-Test ergaenzt.

Bewusst konservativ: Nur dieser wire:model-freie, presentational Block
wurde extrahiert. Die interaktiven Markup-Panels (Tag-Input, Publish-
Schedule, Kontakt-/Firmen-Select) bleiben in den Editoren, da ihre
wire:model-/Alpine-Bindungen ohne visuelle QA nicht risikofrei in
Components zu ueberfuehren sind und die Tests gerendertes Markup nur
begrenzt abdecken.

Tests: volle Suite gruen (800 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:16:21 +00:00
3a18a2b494 Optimierung 2.9: HandlesAdminPressReleaseActions::findAdminPR()
Den ~15x duplizierten Admin-Lookup PressRelease::withoutGlobalScopes()
->findOrFail($id) in einen findAdminPR()-Helfer (Concern) zentralisiert;
index/edit/show nutzen ihn. Die eager-load-Query in show::with() bleibt
unberuehrt (anderer Zweck).

Bewusst NICHT vereinheitlicht: das Status-Badge-Mapping divergiert real
(index: archived/draft -> muted; edit/show: -> hub via default) -> waere
eine Design-Entscheidung ueber kanonische Badge-Farben, keine mechanische
Dedup.

Tests: volle Suite gruen (799 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:11:48 +00:00
86ba6c1a51 Optimierung 2.7: Company::scopeSearchByTerm fuer die Admin-Combobox
Die in admin/create und admin/edit identische Inline-Firmensuche
(Volltext-oder-LIKE) inkl. des 2x duplizierten supportsFullTextSearch()-
Treiберchecks in einen Company::scopeSearchByTerm() zentralisiert; beide
Editoren nutzen ihn jetzt (Verhalten unveraendert). Ungenutzte DB-Importe
entfernt. Customer nutzt bereits CustomerCompanyContext (eigene
join-/user-gescopte Query, daher nicht zusammengelegt).

Tests: neuer Scope-Test; volle Suite gruen (799 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:06:54 +00:00
83d2ee659a Optimierung 2.6: reprocessAfterContentChange im PressReleaseService
Den 3x identischen contentChanged->fresh->reclassifyIfClassified->
rescoreIfScored-Block (admin/edit 1x, customer/edit 2x) in
PressReleaseService::reprocessAfterContentChange() zentralisiert.

Hinweis: Eine vollstaendige createDraft()/updateContent()-Verlagerung
wurde bewusst NICHT erzwungen — die Attributsaetze und der umgebende
Validierungs-/Fehlerfluss von Admin (no_export, direkter review-Status)
und Customer (placeholder_variant, Titel-Fallback, immer Draft) divergieren
real; eine gemeinsame Persistenzmethode waere fragil bei grosser Testflaeche.

Tests: 2 neue Faelle; volle Suite gruen (796 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:01:29 +00:00
8c53146548 Optimierung 2.5: Kontakt-Helfer + tagSuggestionsFor zentralisiert
Erkenntnis: Die Admin-Inline-Query (Contact::withoutGlobalScopes()->where
->orderBy) ist exakt der Contact::forCompany()-Scope -> alle vier
Kontakt-Helfer waren funktional identisch. companyContacts/companyContact/
defaultContactIdFor in neuen Concern InteractsWithPressReleaseContacts
(ueber forCompany) ausgelagert. tagSuggestionsFor() (4x identisch) in
InteractsWithPressReleaseTags verschoben. Ungenutzte Collection-Importe
in den Customer-Editoren entfernt; selectedCompany() bleibt je Kontext
(Admin: withoutGlobalScopes find; Customer: CustomerCompanyContext).

Tests: volle Suite gruen (794 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:59:38 +00:00
b41d34e39f Optimierung 2.4: Kanonische Pre-Submit-Checkliste
presubmitChecks() der vier Editoren in ProvidesPressReleasePresubmitChecks
zentralisiert (war 4x dupliziert mit Drift). Kanonische Version uebernimmt
die bessere Customer-Variante: Firmenname statt leerer Hinweis in der
company-Zeile. Dafuer erhalten die Admin-Editoren ein selectedCompany()
(Company::withoutGlobalScopes find), das auch in with() genutzt wird;
Customer behaelt seine user-gescopte Aufloesung via CustomerCompanyContext.

Tests: volle Suite gruen (794 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:53:06 +00:00
fa995e30a2 Optimierung 2.3: formRules() via ProvidesPressReleaseFormRules
Die ~95% identischen Validierungsregeln (Inhaltsfelder, publishMode,
terminabhängiger Scheduling-Block mit zeitzonenbewusster Closure, Embargo)
in baseFormRules() zentralisiert. Die vier Editor-formRules() ergänzen nur
noch die abweichenden Schlüssel (portal nur Admin, companyId mit/ohne
exists). Ungenutzten Rule-Import in customer/edit entfernt.

Tests: volle Suite gruen (794 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:46:27 +00:00
64349ce6b4 Optimierung 2.2: InteractsWithPressReleaseScheduling-Trait
Die in allen vier PM-Editoren wortgleiche Scheduling-Maschinerie
(updatedPublishMode/ScheduledDate/ScheduledTime, updated, syncScheduledAt,
resolveScheduledAt, validateScheduledAtWhenReady, notifyValidationError)
in einen gemeinsamen Concern ausgelagert (spiegelt InteractsWith-
PressReleaseTags). ~85 Zeilen je Datei entfernt.

Tests: volle Suite gruen (794 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:41:21 +00:00
0344ac26af Optimierung 2.1: Admin-PM-Editoren nutzen Tags-Trait
Admin create/edit nutzen jetzt InteractsWithPressReleaseTags (wie die
Customer-Editoren) statt die byte-gleiche addTag/removeTag/tagsArray-Logik
lokal zu duplizieren. ~48 Zeilen je Datei entfernt.

Tests: 259 PressRelease-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:32:41 +00:00
554a731a24 Optimierung Phase 1: Aufraeumen / toter Code
1.1 Prototyp admin/users/table entfernt: Route (war durch admin/users/{id}
    ohnehin unerreichbar) + Volt-Komponente table.blade.php geloescht,
    ADMIN_ROUTES.md aktualisiert; Regressions-Test in PanelConsolidationTest.
1.2 16 tote Legacy-Wrapper-Views unter resources/views/admin/* geloescht
    (nutzten alte Route-Parameter, nirgends via view/@include/@livewire
    gerendert); admin.dashboard + .md-Doku behalten.
1.3 Tote Helfer entfernt: EnsureUserIsAdmin redundantes loadMissing('roles'),
    ungenutzter statusColor-Key aus with() in admin PR edit/show,
    LegacyInvoicePdfController Laufzeit-Schema::hasColumn (Spalte existiert
    seit Migration) inkl. Schema-Import.

Volle Suite gruen (794 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:27:12 +00:00
c7bd940f28 Optimierung Phase 0: Korrektheit & Security
Konsolidierter Entwicklungsplan aus den zwei Analyse-Dokumenten plus
Umsetzung der Phase-0-Massnahmen (Korrektheit/Security, je mit Tests):

0.1 BasicAuth: timing-sicherer Vergleich (hash_equals, beide Felder ohne
    Short-Circuit); Enabled-Check zur Laufzeit via config('auth.basic.*')
    statt env() im Bootstrap-Closure (greift jetzt unter config:cache);
    Config-Block + .env.example. Fund: auth.basic.* war nie als Config
    definiert, Gate war faktisch unkonfigurierbar.
0.2 Legacy-Invoice-"latest"-Abfrage zentralisiert (LegacyInvoice::
    scopeLatestFirst + User::latestLegacyInvoice); beide Consumer N+1-frei
    umgestellt; unterschiedliches null-Verhalten bewusst erhalten.
0.3 Payment-Option-Eligibility vereinheitlicht (period-aware, strikt):
    UserPaymentOption::scopeActiveNow/grandfatheredNow + User::
    hasActivePaymentOption; hasActiveBooking/hasUnlimitedPressReleaseQuota
    und ApiAccessEligibilityService darauf umgestellt; $hasGrandfathered-
    Mislabel behoben.
0.4 Theme->Portal-Vokabular: totes, projektfremdes ThemeMiddleware
    geloescht; Mapping in Portal::fromTheme() zentralisiert; fremden
    b2in-Default im ThemeServiceProvider auf Portal-Default korrigiert.
0.5 InvoiceNumberGenerator: Erst-Insert race-sicher via insertOrIgnore
    (Unique-Index auf circle existiert bereits). CreditWalletService war
    bereits abgesichert (Unique-Index + createOrFirst).

Tests: 4 neue Dateien, 5 bestehende auf gueltiges grandfathered_until
angepasst. Volle Suite gruen (793 passed, 1 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:09:02 +00:00
6cb7b79dae Doku: Launch-Vorbereitung 18.06. (Status-Widget, WS-8, i18n, Paket-Vorteile)
- Decision-Update: neuer Abschnitt 7 "Launch-Vorbereitung (Stand 18.06.2026)"
  dokumentiert Status-Widget, WS-8-Optimierungen, Paket-Vorteile in der DB,
  i18n-Readiness (Default-Locale de, Views __()-gewrappt, EN-Inhalt bewusst
  spaeter) sowie den Editor-Umbruch-Fix. End-to-End-QA als naechster Schritt.
- WS-7-Checkliste: add_benefits_to_plans_table in die Migrations-Liste
  aufgenommen (APP_LOCALE=de stand bereits drin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:04:17 +00:00
052b214bd9 i18n: Auth-Views vollstaendig uebersetzbar machen (Readiness)
Die Auth-Strecke (Login, Registrierung, Passwort-Reset/-Bestaetigung,
Magic-Link, E-Mail-Verifizierung, 2FA) war als einziger Backend-Bereich
noch nicht durchgaengig __()-gewrappt. Alle sichtbaren deutschen Strings
in __() gewickelt (Deutsch bleibt Schluessel). Eingebettete Links (AGB/
Datenschutz) und Alpine-x-text sauber segmentiert. Heading/Eyebrow und
topRight-Label/-Link sowie der From-Banner werden jetzt im Auth-Layout am
Ausgabepunkt uebersetzt (im #[Layout]-Attribut sind keine __()-Aufrufe
moeglich). Leeres lang/en.json als Geruest fuer die spaetere EN-Befuellung.

Kunden-, Admin-, Settings- und Component-Views waren bereits vollstaendig
gewrappt (per Audit bestaetigt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:00:20 +00:00
f6a8143a2c Preise-Seite: dynamische Tarif-Vorteile aus dem Plan anzeigen
Die oeffentliche Preise-Seite rendert jetzt die im Admin gepflegte
Vorteilsliste (plans.benefits) pro Tarif; faellt ohne gepflegte Vorteile
auf den statischen pricing.plans.included-Text zurueck. DB-Vorteile sind
admin-redaktionelle Texte und erscheinen auch unter /en in der erfassten
Sprache (Preis-/Struktur-Keys bleiben uebersetzt).

Hinweis: betrifft Web-Frontend (WIP des Users), daher separater Commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:37:01 +00:00
e96a11824b Paket-Vorteile: dynamische, im Admin pflegbare Vorteilsliste je Tarif
Neue JSON-Spalte plans.benefits (Migration + Cast + Plan::benefitsList()).
Im Admin (Tarife & Pakete) pro Tarif zeilenweise pflegbar; die In-App-
Buchungsseite rendert die Liste in den Tarif-Karten statt einer festen
Zeile. Seeder/Factory mit Beispiel-Vorteilen. Single Source of Truth fuer
Buchungsseite und (folgt separat) die oeffentliche Preise-Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:36:09 +00:00
7d03deddb1 i18n: Default-Locale auf Deutsch umstellen (Basis fuer EN-Umschaltung)
APP_LOCALE/APP_FALLBACK_LOCALE auf 'de'. Das Backend nutzt durchgaengig
__('Deutscher Text') als Schluessel; mit Default-Locale 'en' wurden zwar
(mangels en.json) ebenfalls die deutschen Schluessel ausgegeben, aber eine
spaeter ergaenzte en.json haette die App bei Locale 'en' ungewollt teilweise
auf Englisch umgestellt. Mit 'de' rendert Deutsch nativ und 'en' wird zum
bewussten Umschalter. Frontend bleibt unveraendert (URL-Prefix /de|/en via
SetEdition + strukturierte lang/de|en/*.php-Keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:31:55 +00:00
1aa43914da WS-8: doppelte Logik konsolidieren (Tags, Kontakt-Query, Billing)
- Themen-Tag-Verwaltung (addTag/removeTag/tagsArray) war in create- und
  edit-Editor byte-identisch dupliziert -> Trait
  App\Livewire\Concerns\InteractsWithPressReleaseTags.
- Kontaktauswahl je Firma (withoutGlobalScopes + where + Sortierung) aus
  create/edit/dashboard in Contact::scopeForCompany() zentralisiert.
- Rechnungsadress-Pflichtfelder als BillingAddress::REQUIRED_FIELDS; isComplete()
  und neues completenessPercent() teilen die Liste, Dashboard nutzt es statt
  eigener Feldliste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:33:59 +00:00
1ff1cc3626 WS-8: N+1 entschaerfen und PM-Zugriffsmuster konsolidieren
- User::currentPlan() und hasUnlimitedPressReleaseQuota() pro Request
  memoisiert. Beide werden je Render mehrfach abgefragt (Status-Widget auf
  jeder Seite, Buchungsseite, Quota-Methoden) und loesten bisher jeweils
  eigene subscription-/Plan-/payment-option-Queries aus. fresh() setzt den
  Cache zurueck.
- show.blade.php: getMyPR() war NICHT memoisiert und wurde pro Request bis
  zu 7x mit voller Eager-Load-Query aufgerufen -> jetzt memoisiert. Firmen-
  Kennzahlen direkt per withCount() eager geladen statt nachtraeglichem
  loadCount().
- Dupliziertes Zugriffsmuster (withoutGlobalScopes + user_id/company_id) aus
  edit/show/index in PressRelease::scopeAccessibleBy(User) zentralisiert
  (Single Source of Truth fuer Kunden-PM-Zugriff), mit Tests abgesichert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:27:39 +00:00
7de0db3547 Status-Widget: kompakte Topbar statt hoher Header-Leiste
Der Flux-Header erzwang min-h-14 und zentrierte das kleine Status-Badge
vertikal — das ergab auf dem Desktop eine fast leere Leiste mit viel
Freiraum zum Inhalt. Header-Hoehe an den Inhalt angepasst (min-h-0,
oben knapper Abstand, unten keiner), sodass das Widget oben sitzt und
der nachfolgende Content direkt anschliesst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:14:24 +00:00
4ab746077c Status-Widget: immer verfuegbarer Buchungsueberblick in der Topbar
Ein Header-Dropdown oben rechts (Desktop) bzw. im Mobile-Header zeigt
fuer alle Kunden-Panel-User den aktuellen Status: aktuelle Buchung/Tarif,
verbleibendes PM-Kontingent und Credit-Guthaben. Ohne aktive Buchung
weist es direkt auf die noetige Buchung hin und verlinkt die Buchungsseite.

CustomerStatusSummary buendelt die Anzeige-Werte ueber die oeffentliche
User-API (Gating bleibt Single Source of Truth im Modell); die anonyme
Blade-Komponente x-customer.status-menu rendert das Dropdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:03:25 +00:00
bcac326814 Fix: Umbrueche bleiben beim Bearbeiten von Legacy-PMs erhalten
Bestehende (importierte) Pressemitteilungen speichern ihren Text als
Plaintext mit \n-Umbruechen. Beim Laden in den HTML-Editor (flux:editor)
kollabierten diese zu einem Fliesssatz, weil HTML Whitespace faltet.

Die Plaintext->HTML-Konvertierung (Doppelumbruch = Absatz, Einzelumbruch
= <br>) lag bisher nur im Anzeige-Pfad (render()). Sie ist jetzt als
toEditorHtml() wiederverwendbar und wird beim Laden des Editors genutzt;
die Link-Policy (rel/target) bleibt dabei dem Anzeige-Pfad vorbehalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:57:45 +00:00
624fca68a8 Zeitzone Europe/Berlin: unnoetige UTC-Konvertierung entfernt
Beim Speichern/Veroeffentlichen waren Termine 2 Stunden versetzt: die
App-Zeitzone stand auf UTC, Termine wurden nach UTC umgerechnet, aber
published_at roh angezeigt.

- config/app.php: timezone UTC -> Europe/Berlin
- PressRelease: DISPLAY_TIMEZONE + setTimezone-Umrechnung entfernt
- Admin-/Customer-Formulare: scheduledAtUtc() -> resolveScheduledAt(),
  parst direkt in App-Zeitzone; Edit-Hydration ohne setTimezone
- ProofPdfRenderer + Boost-Anzeige: setTimezone entfernt
- Tests an verschiebungsfreies Verhalten angepasst

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:40:19 +00:00
326ff9b2a0 Editorial-Frontend: oeffentliche Unterseiten, Preise & Linkpflege
Oeffentliche Strecke durchgaengig auf das Editorial-Design umgestellt und
datengetrieben gemacht:

- Newsrooms-Verzeichnis + Newsroom-Detailseiten (Suche, Pagination)
- Preise-Seite neu (Abos via Plan, Einzel-PM via config/billing, Add-ons via
  config/credits); Texte ausgelagert nach lang/{de,en}/pricing.php
- Pricing-Teaser-Komponente auf den Startseiten + Preise-Link in Header/Footer
- Statische Seiten im Editorial-Design ueber neue Komponente x-web.static-page:
  impressum, datenschutz, agb, cookies, faq (Akkordeon), hilfe, kontakt,
  ueber-uns, api (altes Theme/gradient-hero entfernt)
- Header/Footer-Linkpflege: tote #-Anker raus, FAQ/Hilfe/Cookies verlinkt,
  "Anmelden" fuehrt in den Hub
- Legacy-URL-Redirects (.html, presskit->newsroom, Regionen)
- Datums-Bugfix in Feed-Komponenten

Tests: NewsroomPage, PreisePage, PricingTeaser, StaticPages, NavigationLinks,
LegacyRedirect. Doku in docs/frontend aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:08:46 +00:00
0618080e02 Doku: Umsetzungsstand der Credit-Oekonomie im Decision-Update
Abschnitt 6 'Umsetzungsstand 17.06.2026': Fundament (Wallet/Ledger/Tier/Topup),
Launch-Funktionen (Extra-PM/Boost/Nachweis/UI/Gate), Phase-2-vorbereitet
(Pruefzaehler, Pfade C/D/G), offene Punkte. Nachzug zu dd5a937 (Dateiname
mit NFD-Umlaut wurde beim ersten Commit verfehlt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:02:24 +00:00
6a7483e805 Doku: Credit-Oekonomie in WS-7-Checkliste + Umsetzungsstand im Decision-Update
- WS-7: Credit-Migrationen (wallets/transactions/review_checks/boosts/topups)
  in die Pflichtliste; Hinweis dass Credit-Pakete keine Stripe-Preis-IDs
  brauchen (checkoutCharge, config/credits.php); Webhook erfuellt credit_topup_id
  idempotent; Post-Deploy-Checks fuer Wallet-Topup + Gate-Datenschutz
- Decision-Update: Abschnitt 'Umsetzungsstand 17.06.2026' (Fundament,
  Launch-Funktionen, Phase-2-vorbereitet, offene Punkte)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:01:24 +00:00
78229296ed Fix: kein Datenverlust beim 'Buchung auswaehlen' aus dem Submit-Modal
Bug: Im Submit-Modal navigierte 'Buchung auswaehlen' per wire:navigate direkt
zur Buchungsseite, OHNE vorher zu speichern -> im Erstellen ging die ganze PM
verloren, im Bearbeiten die ungespeicherten Aenderungen.

- Submit-Modal: neuer Prop booking-action; ist er gesetzt, speichert der Button
  ueber eine Livewire-Aktion zuerst und navigiert erst danach
  ('Speichern & Buchung auswaehlen'). Fallback-href nur dort, wo bereits
  gespeichert ist (Detailansicht).
- create/edit: Persistenz in persistDraft()/persistEdit() extrahiert;
  saveDraftAndChooseBooking() speichert den Entwurf bzw. die Aenderungen und
  leitet erst dann zur Buchung (Edit reklassifiziert bei Inhaltsaenderung).
- Beide Submit-Modals mit booking-action verdrahtet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:43:21 +00:00
2c4171106a Veroeffentlichungs-Gate: proaktive Buchungs-Meldung + Schalter test-stabil
- PM-Detailseite (Entwurf/Ueberarbeiten): ist kein Buchungs-Gate erfuellt,
  erscheint statt 'Zur Pruefung einreichen' der Hinweis 'Noch keine Buchung'
  mit Link zur Buchungsseite ('Buchung waehlen'). Entwurf speichern bleibt frei.
- phpunit pinnt BILLING_ENFORCE_BOOKING=false, damit der Gate in Tests
  deterministisch bleibt (Tests, die ihn brauchen, setzen ihn per config)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:32:07 +00:00
dac8fe0eb1 UI-Feinschliff Buchungen: Tarife oben, Credits darunter, Preisliste, USt.-Modal
- Reihenfolge: Tarife -> Einzel-PM -> Credit-Wallet & Add-ons -> Preisliste
- Buchungs-Bestaetigungs-Modal mit USt.-Aufschluesselung (netto/USt./gesamt,
  Reverse-Charge & steuerbefreit) vor Stripe; greift fuer Tarife, Einzel-PM
  und Credit-Pakete (selectBooking)
- Proaktiver Banner oben, wenn die Rechnungsadresse fehlt (Link ins Profil);
  im Modal Adress-Gate statt Stripe-Link
- Extra-PM-Karte nur bei Guthaben > 0 oder aktivem Abo (sonst Preisliste)
- Pruefkontingent ausgeblendet (Phase 2)
- Preisliste: Extra-PM nach Tarif (eigener Tarif markiert), Boost-Laufzeiten,
  Nachweis-PDF

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:26:04 +00:00
31bef7ab49 UI: Credit-Wallet-Hub + Per-PM Add-ons im Backend-Pressekonto
Wallet-Oekonomie im Customer-Bereich sichtbar gemacht:

- Buchungen & Add-ons: Guthaben-Anzeige, Credit-Paket-Topup (Bonus-Staffel),
  Extra-PM-Kauf aus der Wallet mit kontextuellem Mini-Checkout-Hinweis,
  Pruefkontingent-Status (frei/Monat, heute/Tageslimit), Wallet-Verlauf
- PM-Detailseite: Add-ons-Panel fuer veroeffentlichte PMs -- Boost buchen
  (7/14/30 Tage, Gate nur gruen, Verlaengerung), Veroeffentlichungsnachweis
  kaufen + Download. Aktionen ueber BoostService/ProofPdfService

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:02:31 +00:00
bd7022ffaf Wallet-Topup ueber Stripe (Credit-Pakete mit Bonus-Staffel)
End-to-end-Aufladung der Credit-Wallet, spiegelt den Einzel-PM-Fluss:

- Paket-Staffel mit Bonus in config/credits.php (10->10, 25->27, 50->55,
  100->115 Credits; price_cents netto, Stripe Tax ergaenzt USt.)
- credit_topups (Pending->Paid) + CreditTopupService: startTopup legt den
  Pending-Kauf an, fulfill() schreibt die Wallet idempotent gut (credited_at)
- StripeCheckoutService::forCreditTopup via checkoutCharge + credit_topup_id
  Metadata; ProcessStripeWebhook schreibt bei checkout.session.completed gut
- Checkout-Route /admin/me/checkout/credits/{pack} mit Rechnungsadress-Gate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:56:23 +00:00
c89589beb0 Proof-PDF + Extra-PM-Verkauf ueber die Credit-Wallet (Decision-Update 2.1/2.3)
- ProofPdfService: Veroeffentlichungsnachweis 3 Credits pauschal, einmal pro
  PM (Zweitdownload kostenfrei); ProofPdfRenderer erzeugt das PDF on-demand
  aus vorhandenen PM-Daten (kein externer Renderer); GET-Download-Endpoint
  /admin/me/press-releases/{id}/nachweis hinter downloadProof-Policy + Kauf-Gate
- ExtraPmPurchaseService: tier-gestaffelter Nachkauf (19/15/12/10/8) aus der
  Wallet; verbucht als bezahlter SinglePurchase(ExtraPm) und greift damit in
  die bestehende Kontingent-/Slot-Mechanik. InsufficientCreditsException
  liefert das Mini-Checkout-Signal (required/available/shortfall)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:28:08 +00:00
0ff874efc8 Boost-Geschaeftslogik (Decision-Update 2.2)
Bezahlte Platzierung gruener, veroeffentlichter PMs ueber die Credit-Wallet:

- boosts Tabelle (Zeitraum starts_at/ends_at, days, credits_charged)
- BoostService: Gate (nur Published + Green), Preis nach Laufzeit
  (7/14/30 -> 12/20/35 Credits), Mehrfachkauf verlaengert vom laufenden
  Ende, Wallet-Belastung referenziert den Boost im Ledger
- PressRelease::boosts()/isBoosted()/scopeBoosted() als Basis fuer die
  Featured-Platzierung (Frontend-Anbindung bleibt der Web-Strecke ueberlassen)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:22:12 +00:00
460a255df7 Pruefzaehler + Pruefkontingent (Decision-Update Phase-2 vorgezogen)
Eigener Pruef-Zaehler, getrennt von der Credit-Wallet (Paragraph 4.2/4.3):

- review_checks Ledger (eine Zeile je Pruefung, source free|credit,
  charged_credits), aggregiert pro Account/Monat statt pro PM
- ReviewCheckService: Tageslimit (harte Bremse, nicht freikaufbar) ->
  Monats-Freikontingent (tier-gestaffelt 4/12/30/60/120) -> Overflow
  zieht 1 Credit/Pruefung aus der Wallet
- ReviewLimitException fuer das erreichte Tageslimit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:19:37 +00:00
1306849b21 Credit-Wallet + Ledger + Tier-Preisableitung (Fundament)
Echte Credit-Wallet (1 Credit = 1 EUR) mit append-only Ledger als Basis fuer
die Credit-Oekonomie aus dem Decision-Update (Rev. 4):

- credit_wallets (denormalisierter Saldo) + credit_transactions (Ledger,
  vorzeichenbehaftet, balance_after, polymorphe reference)
- CreditWalletService: einziger Schreibpfad, atomar mit Row-Lock,
  InsufficientCreditsException mit shortfall fuer den Mini-Checkout
- Tier-Enum (Einzel/Starter/Business/Pro/Agency) + User::currentTier()
- CreditPricingService: tier-gestaffelte Ableitung aus config/credits.php
  (Extra-PM 19/15/12/10/8, Boost 12/20/35, PDF 3, Depublish 25, Pruef-Quota)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:16:43 +00:00
97d69a1a3a Migration: PM-Datum korrekt übernehmen, UUID stabil, Zwei-Phasen-Runbook
- published_at = Legacy-created_at (Publikationsdatum, Frontend-Sortier-
  schlüssel) statt updated_at, das bei Alt-Daten oft den Massen-/Migrations-
  stempel trägt und ein falsches "frisches" Datum erzeugte.
- created_at/updated_at per forceFill direkt im Import gesetzt (waren nicht
  fillable, wurden still verworfen) – Import allein ist jetzt datumssauber.
- legacy:fix-timestamps korrigiert zusätzlich published_at (status=published).
- PM-UUID bleibt beim Re-Import/Delta-Lauf erhalten (kein Neu-Würfeln).
- MIGRATION-STEPS auf Zwei-Phasen-Strategie umgestellt: migrate, Phase 1 mit
  --force, Phase 2 (Delta) ohne --force, Grandfather-Re-Run, Idempotenz-Tabelle.
- Tests: LegacyPressReleaseDateImportTest (published_at-Quelle, Entwurf,
  Force-Re-Import erhält UUID + Datum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:48 +00:00
7dbc238bdb WS-7: Live-Deployment-Checkliste + WS-6/WS-7 als erledigt dokumentiert
Kompakte Go-Live-Checkliste (Live-.env, Migrationen, einmalige Setups,
Stripe-Webhook, Cache-Warmup, Scheduler/Worker, Post-Deploy-Kontrollen).
WS-6 (Auth/Verifizierung/Google) und WS-7 im Detailplan als erledigt
markiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:35 +00:00
3a1d2e6751 Frontend: Editorial-Relaunch der öffentlichen Strecke + Ausgaben-Routing
Öffentliche Seiten auf gemeinsames Editorial-Design (x-web.site-header/-footer,
Design-Tokens) und Ausgaben-Präfix /{edition}/ (de|en) umgestellt.

- Routing: neue Middleware SetEdition (Locale + URL::defaults), /{edition}-Gruppe
  in routes/web.php, Root-Redirect auf /de, 301 für Legacy-.html-URLs,
  Baseline-Default in AppServiceProvider.
- Neue URL-Schemata: /{edition}/press-release/{slug}, /{edition}/category/{slug}.
- Ausgabe = Sprache: DE/EN-Umschalter (Region/CH/AT entfernt); EditorialClock
  und Livewire-Komponenten sprachdynamisch.
- Detail-, Kategorie- und Veröffentlichen-Seite mit echten Daten neu aufgebaut.
- Suche aktiviert: Volt-Komponente livewire/web/search (Titel/Text/Keywords +
  Firma + Rubrik, Filter, Sortierung, Pagination, URL-Parameter q/category/sort).
- Rubriken-Navigation statt Übersichtsseite: Helper CategoryNavigation;
  web/kategorien.blade.php + Route entfernt (Legacy-301).
- Tests: Edition-Routing, Kategorie-Seite/-Navigation, Detail, Veröffentlichen,
  Suche, EditorialClock. Doku in "Echte öffentliche Unterseiten.md" aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:39:28 +00:00
57afcab3b9 WS-5: Buchungs-Gate launch-ready – proaktiver Dashboard-Hinweis
Das Abo-/Bestandsschutz-Gate (User::hasActiveBooking, BookingRequiredException,
Submit-Modal, API 402/422) war bereits implementiert und getestet. Ergänzt wird
die proaktive Block-UX:

- Kunden-Dashboard zeigt bei scharfem Gate (billing.enforce_booking=true) und
  fehlender aktiver Buchung einen Banner "Buchung erforderlich, um zu
  veröffentlichen" mit Link zur Buchungsseite. Bestandskunden (grandfathered)
  sehen den Hinweis nicht; bei offenem Gate bleibt er unsichtbar.
- Tests: Banner aus (Gate offen) / an (Gate scharf, keine Buchung) /
  Ausnahme für grandfathered.

Launch-Flip bleibt env-gesteuert (BILLING_ENFORCE_BOOKING), Dev-Default false –
.env.example und Detailplan (WS-5 ✅) dokumentieren den Schalter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:37:16 +00:00
c82c2370fa WS-4: Bestandsschutz im Kundenbereich als echtes Paket darstellen
/admin/me/buchungen-add-ons: Bestandsschutz raus aus dem gedrängten
"Aktueller Tarif"-Panel, eigener prominenter Paket-Block:
- Portal-Badge (fehlte bisher), Schild-Icon, Akzentleiste, Rahmen (ring)
- größere Typo, Netto-Preis + Intervall, Feature-Liste (unbegrenzte PMs,
  Konditionen unverändert, nächste Rechnung), Bündel-Hinweis bei mehreren Posten
- Helper legacyPortalLabel()/legacyIntervalLabel() im Volt-Component
- BookingsPageTest auf die neue Darstellung + Portal-Anzeige aktualisiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:29:21 +00:00
f87c470a0b WS-4: Bestandsschutz-Abrechnung – gekoppelte Kündigung + Admin-Sichtbarkeit
M1 (portalübergreifend) und M5 (04:30-Worker) verifiziert; M2 + M3 umgesetzt.

- M2 (Entscheidung: getrennte Rechnungen, gekoppelte Kündigung): neuer
  LegacySubscriptionService::cancelBundleFor() kündigt das gesamte
  Bestandsschutz-Bündel eines Kunden gemeinsam – keine Einzelkündigung. Kein
  Rechnungs-Schema-Umbau (jede Kondition behält ihre MAN-Rechnung).
- M3: Admin-Payments-Ansicht zeigt eine "Bestandsschutz-Posten"-Tabelle
  (Badge, Portal, Netto, nächste Fälligkeit, "Bündel (N)") + Bündel-Kündigung
  mit Bestätigungsmodal.
- Tests: tests/Feature/Billing/LegacyBundleTest.php (M1 cross-portal, M2 Bündel-
  Kündigung + Gate, M3 Admin-Liste + Kündigung). Detailplan WS-4 dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:20:18 +00:00
eaae1e66bc WS-3 abgeschlossen dokumentiert: B abgedeckt, A nach Phase 2 verschoben
E/F/Melden/Queue erledigt (95007da); B über WS-2-Firmen-Scope abgedeckt;
A (Tippfehler an veröffentlichten PMs) bewusst nach Phase 2/Pfad C zurückgestellt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:03:16 +00:00
9bce9afe0b WS-3: Recht & Compliance – Rechts-Kern (DSGVO/Persönlichkeitsrecht/Melden + Queue)
Launch-pflichtiger Compliance-Slice: öffentliche Anfrage zu einer PM speist eine
manuelle Admin-Queue (keine KI).

- Migration legal_requests + Model + Enums (Type: dsgvo/personal_rights/report,
  Status: open/in_progress/resolved/rejected) + Factory.
- Öffentliches Formular /release/{slug}/rechtliches (LegalRequestController +
  web/legal-request.blade.php): typ-abhängiger Hinweistext (Alpine), E-Mail bei
  DSGVO/Persönlichkeitsrecht erforderlich, zwei versteckte Honeypot-Felder,
  Rate-Limit + Bremse "1 offene Anfrage pro PM/Typ". Regeltexte als Entwurf mit
  TODO für rechtliche Finalisierung markiert.
- Routen bewusst in eigener routes/legal.php (entkoppelt vom laufenden Web-Umbau),
  host-agnostisch via domains.php eingebunden.
- Admin-Bereich "Recht & Compliance": Sidebar-Nav mit Offen-Zähler, Volt-Queue
  index/show (in Bearbeitung/erledigt/abgelehnt/wieder öffnen + interne Notiz).
- Tests: je Typ, Honeypots (Dataset), Bremse, Admin-Queue + Status-Übergänge.
- Doku: Detailplan WS-3-Status + Deployment-Migrationsreihenfolge ergänzt.

Hinweis: Der "Melden"-/E&F-Button auf der PM-Detailseite (release-detail.blade.php)
wird mit dem separaten Web-Frontend-Commit verdrahtet; Ziel ist legal-request.create.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:20:05 +00:00
64311392db WS-6: Google-Redirect-URI auf Live-Domain pressekonto.com korrigiert
Live-Portal-Domain ist pressekonto.com (nicht .de) – Callback-URL in
.env.example und Deployment-Doku entsprechend gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:33:07 +00:00
46d7ae3c60 WS-6: Google-Redirect-URI klargestellt (Portal-Domain, .de) + lokaler Testhinweis
- .env.example: GOOGLE_REDIRECT_URI explizit auf die Portal-Callback-URL statt
  ${APP_URL}; Hinweis, dass Google keine .test-Domains akzeptiert.
- Deployment-Doku (§6c): exakte Prod-Callback-URL (pressekonto.de, nicht .com),
  lokaler Round-Trip via localhost/https-Tunnel, sonst über gemockte Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:29:29 +00:00
fec2148ebb WS-2: Magic-Link für Firmen & Pressekontakte vereinheitlicht + Schreibzugriff
Magic-Link und Pressekontakt-Zugang zu einer Seite (/anmeldelink) zusammengeführt;
altes Login-Modal entfernt, /pressekontakt-zugang leitet weiter.

- ContactAccessService deckt jetzt Firmen-E-Mail UND Pressekontakt-E-Mail ab,
  portalübergreifend (ohne PortalScope). Eine E-Mail mehrfach hinterlegt → genau
  ein Account, dem alle Firmen + Kontakte zugeordnet werden.
- Zugeordnete Firmen erhalten Pivot-Rolle 'responsible' (Schreibzugriff auf
  Stammdaten, Kontakte, Pressemitteilungen) statt nur 'member'; bestehende
  Lese-Pivots werden hochgestuft, Owner bleiben unangetastet.
- Neuer Login-Listener (SyncCompanyMembershipsOnLogin) frischt die Zuordnungen
  bei JEDEM Login (Magic-Link, Passwort, Google) auf – auch nachträglich (API)
  hinzugekommene Firmen/Kontakte mit gleicher E-Mail greifen.
- Auth-Bereich erzwingt Hellmodus: aus dem Portal übernommene .dark-Klasse wird
  am <html> entfernt (Login war im Dark Mode hängengeblieben).
- Tests: Firmen-E-Mail-Login, Multi-Firmen-Aggregation, Schreibzugriff/Upgrade,
  Per-Login-Re-Sync, Auth-Hellmodus. Sicherheits-Doku aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:55:49 +00:00
265d1fde53 WS-6: Google-Login via Laravel Socialite
- Socialite installiert; oauth_provider/oauth_provider_id an users (Migration).
- GoogleController (redirect/callback) + SocialAuthService: De-Dup über E-Mail,
  neuer User aktiv + verifiziert + customer (Verifizierung über den Google-
  Kanal), offener Selbst-Registrierer wird onboardet, deaktivierter Account wird
  NICHT reaktiviert. Abschluss über die gemeinsame LoginRedirect-Logik
  (rollengerecht, 403-sicher).
- Routen /auth/google/redirect + /auth/google/callback (guest), "Mit Google
  anmelden/registrieren"-Buttons auf Login und Register.
- config/services.php google + .env.example-Keys; Sicherheits-/Deployment-Doku
  ergänzt (Keys, Redirect-URI, Migration).

Tests: neuer User, De-Dup bestehender User, deaktivierter Account blockiert,
unverifizierter Registrierer onboardet, fehlgeschlagener Callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 10:39:19 +00:00
42b42887b7 Security: JSON-Login durchläuft die is_active-/Verifizierungschecks
RoleAwareLoginResponse gab bei wantsJson() sofort 204 zurück – VOR den
Sicherheitschecks. Ein XHR/JSON-Login eines verifiziert-inaktiven Accounts
erhielt damit eine Session ohne Logout. Checks laufen jetzt zuerst:
verifiziert-inaktiv → Logout + Session-Invalidate + 403 (JSON) bzw. Login mit
Fehler (HTML); unverifiziert → 204 (JSON) bzw. Notice (HTML); danach der
Erfolgsfall.

Tests: JSON-Login eines inaktiven Accounts (403, guest), JSON-Login eines
aktiven Users (204, authentifiziert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 10:19:32 +00:00
512057cbfd Security: 2FA-Bypass beheben & Login-Pfade konsolidieren
Befund (Review 16.06.): Der Volt-Login machte direkt Auth::attempt() und umging
Fortifys 2FA-Pipeline (2FA-Bypass); zusätzlich existierte der Fortify-POST /login
parallel mit schwächeren Post-Login-Regeln.

Fix (Volt-nativ):
- Volt-Login prüft Credentials ohne sofortiges Login; bei aktivem 2FA wird der
  Session-Vertrag login.id/login.remember gesetzt und auf eine neue Volt-
  2FA-Challenge-Seite (/two-factor-challenge) geleitet, die an Fortifys
  bestehenden Controller postet (TOTP + Recovery-Code).
- Gemeinsame Post-Login-Logik in App\Support\LoginRedirect (rollengerechtes
  Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND Response.
- RoleAwareLoginResponse implementiert jetzt LoginResponse UND
  TwoFactorLoginResponse und erzwingt einheitlich: unverifiziert → Notice,
  verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist
  auch der direkte Fortify-POST-Pfad gehärtet.

Tests: 2FA-Übergabe, Challenge-Guard, voller TOTP-Flow, Fortify-POST blockt
inaktive User und hält Customer aus dem Admin-Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 10:00:15 +00:00
f28b698c8d Security-Härtung Login & Magic-Link (Review 16.06.)
- Magic-Link-Versand im Login rate-limited (E-Mail+IP 3/h und IP-only 15/h);
  verhindert Mail-Fluten und das Entwerten aktiver Links.
- Inaktive (aber verifizierte) User werden beim Passwort-Login zentral
  blockiert (Auth::logout + Fehler) – sichert nur-auth/verified-Routen ab.
- Rollensicherer Login-Redirect: gemerkte intended-Admin-URLs schicken einen
  Customer nicht mehr in den 403, sondern auf das rollengerechte Ziel.
- ContactAccess prüft is_active vor jeder Mutation: deaktivierte Bestands-
  Accounts werden durch eine Anfrage weder verändert noch angemailt.
- Magic-Link-Verbrauch atomar (UPDATE … whereNull(consumed_at)) – Single-Use
  auch bei parallelen Requests.
- Sicherheits-Doku um diese Härtungen + Captcha-Empfehlung ergänzt.

Tests: Rate-Limit, intended-Admin-URL für Customer, inaktiver Login,
ContactAccess ohne Mutation inaktiver Accounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:33:44 +00:00
55d974f9e8 Doku: Sicherheits- & Deployment-Hinweise (Auth, Rollen, Verifizierung)
Dokumentiert die deployment-kritischen und sicherheitsrelevanten Punkte der
Auth-Phase: Reihenfolge der Migrationen (Verifizierungs-Backfill gegen Lockout,
Legacy-editor→customer-Downgrade), der Sicherheits-Fix der Editor-Über-
berechtigung (65.950 Legacy-User hatten Admin-Zugriff), die Firmen-Scope-
Verhaltensänderung bei PMs und der Magic-Link-Kontaktzugang. PROGRESS-Log-
Eintrag (16.06.) und README-Index ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:35:04 +00:00
c6b2b67d00 WS-2: Firmen-Scope für PMs & Magic-Link-Zugang für Pressekontakte
Firmen-Scope (Fundament):
- PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER
  Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user-
  Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der
  Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/
  canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten
  alle PMs ihrer Firma.

Magic-Link-Zugang für Pressekontakte (ContactAccessService):
- Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit
  Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy
  angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den
  Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet
  wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume
  wiederverwendet) – keine Schema-Änderung, kein paralleles System.
- Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später).

Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup,
Enumeration-Sicherheit, Honeypot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:33:12 +00:00
f06650a641 WS-6: E-Mail-Verifizierung, Auth-Flow-Fixes & Legacy-Rollen-Sicherheitsfix
E-Mail-Verifizierung (Entscheidung 15.06.):
- User implementiert MustVerifyEmail; Registrierung legt inaktives, rollenloses
  Konto an und leitet auf die Danke-/Notice-Seite; Registered-Event versendet die
  Verifizierungsmail. Bestätigter Link aktiviert das Konto + vergibt customer-Rolle
  (ActivateUserAfterVerification). Backfill-Migration setzt email_verified_at für
  alle Bestands-User (sonst würde die verified-Middleware ~59k aktive Legacy-User
  aussperren). Seeder-User verifiziert.

Auth-Flow-Korrekturen:
- Magic-Link-Consume: rollensicherer Redirect ohne intended() (Customer landete
  sonst per stale intended=/dashboard im 403-Admin-Bereich).
- Guest-Redirect (bootstrap/app.php) rollen-/verifizierungsbewusst statt fix
  /dashboard – schließt die 403-Sackgasse auf /login und /register.
- Logout auf der Notice-Seite via echtes POST-Formular statt Livewire-Action
  (behebt 419 beim Session-Invalidate).
- Magic-Link-Anforderung über eigenes Modal mit separater E-Mail-Eingabe.
- Unverifizierte Login-Versuche landen auf der Notice-Seite.

Sicherheitsfix Legacy-Rollen:
- UserImporter mappte Alt-Gruppe 2 (Self-Publisher) auf editor (= Admin-Zugriff).
  Mapping auf customer korrigiert; Daten-Migration stuft die 65.950 fälschlichen
  Legacy-Editoren auf customer herab. Echte admin/api-only bleiben unberührt.

Tests: Registration, EmailVerification, Authentication (Guest-Redirect),
MagicLinkLogin (Modal/Redirect/Regression), Legacy-Import (Gruppen-Mapping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:16:41 +00:00
e54f6e2552 WS-1: Canonical- & OpenGraph-Meta zentral im Web-Layout
Ergänzt web-master.blade.php um <link rel="canonical">, OpenGraph- und
Twitter-Card-Tags. Self-Canonical-Default wird portal-getrennt aus der
Portal-URL ($domainUrl) + Request-Pfad gebaut – bewusst nicht aus
url()->current(), da URL::forceRootUrl sonst alle Portale fälschlich auf
pressekonto.test kanonisieren würde. Seiten können canonical/meta_description/
og_type/og_image per @section überschreiben; release-detail als article
ausgezeichnet. Erfüllt die Canonical-Hygiene aus dem Duplicate-Content-Update §5.

Tests: tests/Feature/Web/CanonicalMetaTest.php (inkl. Cross-Portal-Negativtest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:03:15 +00:00
ce5d6c98e1 Detailplan Umsetzungsphase: Launch-Slice aus Decision-Updates + Merkliste
Bündelt die Launch-Anteile der beiden Decision-Updates (Phase-2/Magic-Link,
Duplicate-Content) mit den Merkliste-Notizen zu einem Plan (WS-1 bis WS-8) inkl.
empfohlener Reihenfolge und Anti-Zombie-Check. Verbindliche Entscheidungen
(15.06.): Legacy-Posten bleiben ungemappt, Pflicht-E-Mail-Verifizierung bei
Registrierung (Ausnahme Magic-Link/Google), Laravel Socialite freigegeben.
README-Index um den Plan ergänzt; Merkliste (Wochenend-Notizen) als Quelle
mit aufgenommen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:03:05 +00:00
234772be9f Doku-Abschluss 12.06.: README-Stand, weiteres/-Index, Next Steps (Phase-2/Magic-Link, Duplicate-Content)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:24:28 +00:00
4052225199 PM-Vorschau: Firmen-Kachel im Stil der Firmenübersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:23 +00:00
b52751f6db PM-Vorschau: Firma & Pressekontakt zusammengeführt, Sprache als Badge am Portal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:11:57 +00:00
ced39dd371 KI-generierte Bilder: eigener Lizenztyp, Anbieter-Bestätigung, Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:04:12 +00:00
5ca76c7d8d Titelbild: Bildnachweis als Pflichtfeld, Lizenzdetail-Reset bei Typwechsel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:44:52 +00:00
a193097e7d Titelbild-Upload: Bildrechte in 5 Schritten + große Bildvorschau
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:34:40 +00:00
1f31e108d0 Verlinkung & Backlinks: systemseitige rel-Auszeichnung (Decision-Update 11.06.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:24:20 +00:00
ad75886929 PM-Vorschau: Workflow oben + farbig, Metadaten, saubere Inhalts-Typografie
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:16:52 +00:00
6ba260e2d6 Profil-Feinschliff: Pflicht-Badges, Fokus-Fix, Submit-Modal auf Switches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:02:11 +00:00
d07b13810c User-Panel-Restarbeiten: PM-Guard, Profil-Rework, USt-ID-Prüfung, Buchungspflicht-Adresse
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:36:18 +00:00
b126254d53 Responsive-Härtung: Seiten-Header, Kontextleiste, Stat-Cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:08:08 +00:00
c5907dacda Admin-Zahlungsmodul: Zahlungs-Übersicht + Tarif-Verwaltung mit Stripe-Sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:54:53 +00:00
840cc0d279 Checkout: Stripe-Tax-Adressanforderung erfüllen
Stripe Tax verlangt eine gültige Kundenadresse. Beide Checkout-Sessions
erfassen jetzt die Rechnungsadresse verpflichtend und speichern sie am
Stripe-Customer (customer_update address/name = auto; Name ist Pflicht
bei aktivierter USt-ID-Abfrage). Zusätzlich liefert User::stripeAddress()
die lokale Rechnungsadresse bei der Customer-Anlage mit (Cashier-Hook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:33:58 +00:00
5555be4f4d Buchungs-Seite: Feinschliff nach Review
- Aktueller-Tarif-Card erscheint erst mit vorhandener Buchung; die
  Kontingent-Kachel zeigt nur noch echte Zahlen (kein irreführendes
  "Unbegrenzt" vor dem Launch-Schalter)
- Tarif-Cards plakativer: Icon je Tarif, größerer Preis, Trennlinie vor
  den Leistungen, mehr Abstand zum größeren Buchen-Button
- "Prüfung und Veröffentlichung inklusive" statt "KI-Prüfung"
- "Aktive Buchungen"-Panel entfernt (redundant zum Tarif-Panel);
  Verlauf als eigene, durch Trennlinie abgegrenzte Sektion
- Tests angepasst; Suite 519 passed / 4 skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:30:06 +00:00
7818c57e35 Phase 9F: Tarif-Seite mit Stripe-Checkout und Billing Portal
- Buchungs-Seite zeigt das echte 4-Tier-Raster aus plans (Monat/Jahr-
  Toggle, Jahrespreis als "2 Monate gratis") mit Checkout-Buttons,
  Einzel-PM als separaten No-Abo-Block und Enterprise-Hinweis;
  Credit-Konzept-Mock entfernt (Credits folgen mit 9I bzw. Phase 2)
- Aktueller-Tarif-Panel real: Abo (Preis, Kontingent, Kündigungsstatus),
  Bestandstarif (unbegrenzt, nächste MAN-Rechnung), offene Einzelkäufe;
  Kontingent-Kachel zeigt "Unbegrenzt" bei Bestandsschutz
- "Abo verwalten" über das Stripe Billing Portal
  (me.checkout.billing-portal; Zahlungsmethode, Rechnungen, Kündigung)
- Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung,
  offene/eingelöste Einzelkäufe mit PM-Verknüpfung)
- Tests: BookingsPageTest (9 Tests), PanelConsolidationTest angepasst;
  Suite 519 passed / 4 skipped
- Doku: PHASE-9-Plan 9F ✅, Billing-Doku (Routen, Stripe Tax aktiviert),
  STATUS-ABGLEICH, Checkliste, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:39:39 +00:00
ed99ee29fb Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub
- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E ✅, Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:10:32 +00:00
b804dba5c6 Phase 9E (Backbone): Stripe-Produkt-Sync und Webhook-Verarbeitung mit STR-Spiegelung
- billing:sync-stripe-plans: legt die Tarife als Netto-Produkte/Preise
  (tax_behavior exclusive, EUR) in Stripe an und pflegt die IDs zurueck
  nach plans; idempotent, --dry-run. Gegen Stripe Test-Mode ausgefuehrt —
  alle 4 Tiers verknuepft.
- ProcessStripeWebhook (Listener auf Cashier WebhookReceived):
  - invoice.payment_succeeded -> Spiegelung in den lokalen STR-Kreis
    (fortlaufende Nummer via InvoiceNumberGenerator, Adress-Snapshot
    bevorzugt aus dem Stripe-Payload inkl. lokaler USt-ID, Status paid,
    idempotent gegen doppelte Zustellung)
  - checkout.session.completed -> markiert den referenzierten
    single_purchases-Datensatz als bezahlt (Metadata single_purchase_id)
- CASHIER_CURRENCY=eur (+ Locale de_DE); Cashier-Webhook-Route aktiv
- Doku: Billing-Referenz §7 + Phase-9-Plan (9E-Backbone) aktualisiert

Offen fuer 9E-Rest: Checkout-Flows (Abo + Einmalkauf), Webhook-Endpoint
im Stripe-Dashboard + STRIPE_WEBHOOK_SECRET, Slot-Logik auf
Plan-Kontingent (fachliche Frage: Grandfathered = unbegrenzt?).

Tests: StripeWebhookProcessingTest (7, inkl. Event-Wiring).
Suite: 497 passed, 4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:07:09 +00:00
a9dd565f6c Doku: zentrale Billing-Referenz und Status-Sync Phase 9D
- Neues Dokument docs/user-admin/Billing-und-Rechnungskreise.md:
  hybride Rechnungskreise (STR-/MAN-/Archiv), Tarif-Datenmodell,
  MAN-Faelligkeitslauf, USt-Regeln, Befehle/Scheduler, Konfiguration
  (billing.php + Cashier-ENV) und offene Punkte
- README-Index + STATUS-ABGLEICH (Finanzen-Sektion) aktualisiert
- PROGRESS-Eintrag Phase 9D (Datenmodell, Rechnungskreise,
  Grandfather-Migration, USt)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:02:45 +00:00
808d94f29f USt-Behandlung: Netto-Preise, VatResolver und Steuer-Ausweis im MAN-Kreis
Einwand/Entscheidung 12.06.2026: Legacy fakturierte brutto (Steuer
inkludiert, z. B. 199 Euro; steuerbefreite Kunden mit Netto-Ausweis
167,23). Alle neuen Preise sind netto; die Steuer wird zur
Rechnungsstellung sauber validiert und ausgewiesen.

- VatResolver + VatTreatment: DE grundsaetzlich immer mit Steuer, EU nur
  mit (formal plausibler) USt-ID befreit (Reverse Charge inkl.
  Pflichthinweis), Drittlaender grundsaetzlich befreit;
  EU-Laenderliste + vat_rate in config/billing.php
- Schema: billing_addresses.vat_id + invoice_billing_addresses.vat_id
  (Snapshot pro Rechnung), invoices.tax_note; Profil-Formular schreibt
  die vorhandene USt-ID jetzt auch an die Rechnungsadresse
- ManualInvoiceService: rechnet auf Netto-Vertragsbasis
  (legacy_conditions.net_cents bzw. Netto-Katalogpreis) und bestimmt
  Steuer/is_netto/tax_note pro Rechnung ueber den VatResolver
- legacy:grandfather-subscriptions: leitet net_cents aus der letzten
  Legacy-Rechnung ab (brutto / 1,19 bzw. is_netto-Betrag direkt);
  fuer DE-Bestandskunden bleibt der Bruttobetrag unveraendert
  (199 brutto -> 167,23 netto + 31,77 USt = 199,00)
- Doku: Decision-Update 2.1 (Netto-Klarstellung), Phase-9-Plan,
  Checkliste, 05-DATABASE-MERGE 5.6; offen: VIES-Validierung der USt-ID

Tests: VatResolverTest (Datasets fuer alle Faelle), Reverse-Charge/
EU-/Drittland-Rechnungen, Netto-Ableitung; Suite 490 passed, 4 skipped.
Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:58:43 +00:00
9f610f42d9 P6.6: legacy:grandfather-subscriptions — aktive Legacy-Abos aus dem Rechnungsarchiv migrieren
Kriterien vom Auftraggeber (12.06.2026): Quelle der Aktiv-Erkennung ist
ausschliesslich das read-only Rechnungsarchiv legacy_invoices (D-12).
Legacy-Rechnungen bleiben Archiv; neue manuelle Rechnungen entstehen im
MAN-Rechnungskreis.

- Aktiv-Regel: juengste Rechnung pro (Portal, Legacy-Vereinbarung) mit
  payment_option.type=recurring und user_payment_option.status=active;
  next_due_date max. --grace-months (Default 12) ueberfaellig, sonst
  stale -> bleibt reines Archiv. Einmal-Kaeufe werden nie uebernommen.
- Uebernahme als grandfathered in user_payment_options:
  current_period_end = next_due_date, Betraege/Intervall der letzten
  Legacy-Rechnung in legacy_conditions -> der taegliche MAN-Lauf
  (billing:generate-manual-invoices) fakturiert zum gewohnten
  jaehrlichen Rhythmus weiter. Versteckte Katalog-Platzhalter
  LEGACY-{PE|BP}-{Artikel} in payment_options.
- Replay-faehig (D-18): Re-Runs aktualisieren anhand der Legacy-IDs in
  legacy_conditions statt zu duplizieren — die Kern-Migration laeuft
  kurz vor dem Relaunch erneut.
- Optionen: --dry-run, --as-of, --grace-months, --no-report; JSON-Report
  nach storage/app/migration/. Dry-Run gegen Test-Snapshot: 22 aktive
  jaehrliche Vereinbarungen, davon 4 sofort faellig, 0 stale.
- Doku: MIGRATION-STEPS.md (Runbook-Reihenfolge nach archive-invoices),
  05-DATABASE-MERGE §5.6, 12-NAECHSTE-SCHRITTE 6.6, 08-PROGRESS,
  PHASE-9-Plan + Checkliste.

Tests: GrandfatherLegacySubscriptionsTest (7, inkl. End-to-End
Migration -> MAN-Rechnung mit Legacy-Betraegen). Suite: 475 passed,
4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:35:48 +00:00
d2501ca552 Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN-
Tarif-Datenmodell (Decision-Update):
- plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres =
  10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder
- single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
  Status-Lifecycle und Stripe-Checkout-Referenzen
- laravel/cashier ^16.5 installiert (freigegeben); User ist Billable,
  Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation
  ueberschreibt bewusst die Cashier-Methode

Hybride Rechnungskreise (Entscheidung 12.06.2026):
- invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende
  Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den
  manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert
- ManualInvoiceService + billing:generate-manual-invoices (Scheduler
  taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne
  Stripe-Subscription auf erreichtes Periodenende, friert die
  Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus
  (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter;
  Konditions-Overrides via legacy_conditions, sonst Netto-Preis +
  billing.vat_rate; nicht abrechenbare Faelle werden geloggt und
  beim naechsten Lauf erneut geprueft

Submit-Gate:
- User::hasActiveBooking() prueft jetzt echt (hinter
  billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf
  oder laufende Legacy-Vereinbarung (MAN-Kreis)

Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean.
Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf
Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in
user_payment_options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:15:46 +00:00
6b0ca3f2e0 Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate
9A — Gelb geht direkt live (Entscheidung 12.06.2026):
- routeByClassification(): Gelb durchlaeuft denselben Auto-Publish-Pfad
  wie Gruen (autoPublishApproved); nur Rot wird abgelehnt
- Scheduler publiziert faellige gelbe + gruene PMs; unklassifizierte
  bleiben als Fallback in der manuellen Queue

9B — Slot-Verbrauch bei Veroeffentlichung (Decision-Update 3.2):
- Increment aus submitForReview() entfernt; publish() und
  changeStatusFromAdmin() zaehlen idempotent beim ersten
  published-Uebergang (Pruefung ueber Status-Logs); Rot kostet nichts
- Submit-Guard: Einreichen erfordert freien Slot
  (QuotaExceededException, API 422)

9C — Submit-Gate vorbereitet (Decision-Update 5.1):
- User::hasActiveBooking()-Stub hinter config/billing.php
  (enforce_booking, Default aus); Tarif-Modul ersetzt nur den Rumpf
- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis;
  Server-Guard (BookingRequiredException), API antwortet 402
- Fix: Customer-Create legte PMs bei "Zur Pruefung senden" direkt mit
  Status review an (vorbei an Blacklist/Quota/KI/Status-Log) — laeuft
  jetzt immer ueber submitForReview()

Suite: 451 passed, 4 skipped (9 neue Tests). Pint clean.
Plan: docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md (Block 2 nach Review-Stopp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:47:06 +00:00
b2b5ff06c2 Doku: Status-Sync 11./12.06., Decision-Update Preisstruktur und Phase-9-Plan
- Decision-Update Preisstruktur & Veroeffentlichungs-Flow aufgenommen
  (Launch-Tarife, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate,
  Launch-Credits) inkl. Klarstellung 12.06.: Gelb geht direkt live,
  keine manuelle Pruef-Queue, nur Rot wird abgelehnt
- Alle Status-Dokumente auf den Code-Stand gezogen: README-Index,
  STATUS-ABGLEICH (KI-Pipeline, Bilder/Lizenzen, Pricing), Checkliste
  (KI- und Titelbild-Bloecke, Launch-To-dos), Admin-User,
  user-zusammenhaenge (Datenmodell-Delta), Entwicklungsplan KI-Pruefung
  (Phase 0 abgehakt, Decision-Abgleich)
- Ueberschriebene Tarif-Abschnitte in Konzept-Update 1/2 und
  Relaunch-Konzept mit Superseded-/IST-Hinweisen markiert
- Neues Plan-Dokument PHASE-9-FLOW-UND-TARIFE-PLAN.md (9A-9J)
- Phase-8-Roadmap-Doku (20-PHASE-8-USER-PANEL.md) + PROGRESS-Eintraege

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:20:22 +00:00
48993f5506 User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:30:13 +00:00
ba0ee120f1 Multi-Domain-Asset-Infrastruktur: geteilte Vite-Konfiguration und DomainAssetContext
- vite.shared.js als gemeinsame Quelle fuer Ports, Hot-Files, HMR-Hosts
  und CORS-Origins der beiden Vite-Builds (Portal/Web)
- App\Support\DomainAssetContext kapselt die Vite-Build-Directory-
  Konfiguration pro Domain (ThemeServiceProvider + Auth-Layout nutzen ihn)
- Tailwind-Portal-Content-Globs auf die tatsaechliche View-Struktur gezogen
- Dev-Beispiel-Routen + Tests (DomainAssetContextTest, DevExampleRoutesTest)
- Aufraeumen: versehentliche Leerdatei dev:web entfernt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:09 +00:00
a8d12283bf 29-05-2026 Optimierungen Fixes am Code 2026-05-29 12:42:05 +00:00
fd1f6bd401 22-05-2026 Optimierung der User und Admin Panels 2026-05-22 11:18:59 +02:00
36c6499770 create PM v0.5 2026-05-20 19:14:39 +02:00
9844b41535 Rebrand Hub+Flux 2026-05-20 15:44:15 +02:00
36810ac81b 19-05-2026 Rebrand Pressekonto, Hub-Flux UI und Legacy-Media-Migration
Umbenennung presseportale → pressekonto in Domains, Themes und Dokumentation.
Design-Tokens, Portal-Shell, Customer-Dashboard, Auth- und Admin-PM-Views.
Artisan-Befehl migrate:legacy-media mit Tests und Hub-Flux-Entwicklungsdocs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 16:36:13 +00:00
8bfa378028 13-05-2026 Frontend DEV + HUB 2026-05-13 18:11:03 +02:00
313270b55f 12-05-2026 Frontend dev 2026-05-12 18:32:33 +02:00
1467 changed files with 159464 additions and 12468 deletions

View file

@ -0,0 +1,84 @@
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
author: laravel
---
# Flux UI Development
## Documentation
Use `search-docs` for detailed Flux UI patterns and documentation.
## Basic Usage
This project uses the Pro version of Flux UI, which includes all free and Pro components and variants.
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```
## Available Components (Pro Edition)
Available: accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
## Icons
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
```bash
vendor/bin/sail artisan flux:icon crown grip-vertical github
```
## Common Patterns
### Form Fields
<!-- Form Field -->
```blade
<flux:field>
<flux:label>Email</flux:label>
<flux:input type="email" wire:model="email" />
<flux:error name="email" />
</flux:field>
```
### Tables
<!-- Table -->
```blade
<flux:table>
<flux:table.columns>
<flux:table.cell>Column Name</flux:table.cell>
</flux:table.columns>
<flux:table.row>
<flux:table.cell>Value</flux:table.cell>
</flux:table.row>
</flux:table>
```
## Verification
1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness
## Common Pitfalls
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage

View file

@ -0,0 +1,131 @@
---
name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT
metadata:
author: laravel
---
# Laravel Fortify Development
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
## Documentation
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
## Usage
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
## Available Features
Enable in `config/fortify.php` features array:
- `Features::registration()` - User registration
- `Features::resetPasswords()` - Password reset via email
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
- `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
> Use `search-docs` for feature configuration options and customization patterns.
## Setup Workflows
### Two-Factor Authentication Setup
```
- [ ] Add TwoFactorAuthenticatable trait to User model
- [ ] Enable feature in config/fortify.php
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Set up view callbacks in FortifyServiceProvider
- [ ] Create 2FA management UI
- [ ] Test QR code and recovery codes
```
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Email Verification Setup
```
- [ ] Enable emailVerification feature in config
- [ ] Implement MustVerifyEmail interface on User model
- [ ] Set up verifyEmailView callback
- [ ] Add verified middleware to protected routes
- [ ] Test verification email flow
```
> Use `search-docs` for MustVerifyEmail implementation patterns.
### Password Reset Setup
```
- [ ] Enable resetPasswords feature in config
- [ ] Set up requestPasswordResetLinkView callback
- [ ] Set up resetPasswordView callback
- [ ] Define password.reset named route (if views disabled)
- [ ] Test reset email and link flow
```
> Use `search-docs` for custom password reset flow patterns.
### SPA Authentication Setup
```
- [ ] Set 'views' => false in config/fortify.php
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
- [ ] Set up CSRF token handling
- [ ] Test XHR authentication flows
```
> Use `search-docs` for integration and SPA authentication patterns.
#### Two-Factor Authentication in SPA Mode
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
```json
{
"two_factor": true
}
```
## Best Practices
### Custom Authentication Logic
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
### Registration Customization
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
### Rate Limiting
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
## Key Endpoints
| Feature | Method | Endpoint |
|------------------------|----------|---------------------------------------------|
| Login | POST | `/login` |
| Logout | POST | `/logout` |
| Register | POST | `/register` |
| Password Reset Request | POST | `/forgot-password` |
| Password Reset | POST | `/reset-password` |
| Email Verify Notice | GET | `/email/verify` |
| Resend Verification | POST | `/email/verification-notification` |
| Password Confirm | POST | `/user/confirm-password` |
| Enable 2FA | POST | `/user/two-factor-authentication` |
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
| 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |

View file

@ -0,0 +1,190 @@
---
name: laravel-best-practices
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
license: MIT
metadata:
author: laravel
---
# Laravel Best Practices
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
## Consistency First
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
## Quick Reference
### 1. Database Performance → `rules/db-performance.md`
- Eager load with `with()` to prevent N+1 queries
- Enable `Model::preventLazyLoading()` in development
- Select only needed columns, avoid `SELECT *`
- `chunk()` / `chunkById()` for large datasets
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
- `withCount()` instead of loading relations to count
- `cursor()` for memory-efficient read-only iteration
- Never query in Blade templates
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
- `addSelect()` subqueries over eager-loading entire has-many for a single value
- Dynamic relationships via subquery FK + `belongsTo`
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
- `setRelation()` to prevent circular N+1 queries
- `whereIn` + `pluck()` over `whereHas` for better index usage
- Two simple queries can beat one complex query
- Compound indexes matching `orderBy` column order
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
### 3. Security → `rules/security.md`
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
- No raw SQL with user input — use Eloquent or query builder
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
- Validate MIME type, extension, and size for file uploads
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
### 4. Caching → `rules/caching.md`
- `Cache::remember()` over manual get/put
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
- `Cache::memo()` to avoid redundant cache hits within a request
- Cache tags to invalidate related groups
- `Cache::add()` for atomic conditional writes
- `once()` to memoize per-request or per-object lifetime
- `Cache::lock()` / `lockForUpdate()` for race conditions
- Failover cache stores in production
### 5. Eloquent Patterns → `rules/eloquent.md`
- Correct relationship types with return type hints
- Local scopes for reusable query constraints
- Global scopes sparingly — document their existence
- Attribute casts in the `casts()` method
- Cast date columns, use Carbon instances in templates
- `whereBelongsTo($model)` for cleaner queries
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
### 6. Validation & Forms → `rules/validation.md`
- Form Request classes, not inline validation
- Array notation `['required', 'email']` for new code; follow existing convention
- `$request->validated()` only — never `$request->all()`
- `Rule::when()` for conditional validation
- `after()` instead of `withValidator()`
### 7. Configuration → `rules/config.md`
- `env()` only inside config files
- `App::environment()` or `app()->isProduction()`
- Config, lang files, and constants over hardcoded text
### 8. Testing Patterns → `rules/testing.md`
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
- `assertModelExists()` over raw `assertDatabaseHas()`
- Factory states and sequences over manual overrides
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
- `recycle()` to share relationship instances across factories
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios
### 10. Routing & Controllers → `rules/routing.md`
- Implicit route model binding
- Scoped bindings for nested resources
- `Route::resource()` or `apiResource()`
- Methods under 10 lines — extract to actions/services
- Type-hint Form Requests for auto-validation
### 11. HTTP Client → `rules/http-client.md`
- Explicit `timeout` and `connectTimeout` on every request
- `retry()` with exponential backoff for external APIs
- Check response status or use `throw()`
- `Http::pool()` for concurrent independent requests
- `Http::fake()` and `preventStrayRequests()` in tests
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
- Event discovery over manual registration; `event:cache` in production
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
- Queue notifications and mailables with `ShouldQueue`
- On-demand notifications for non-user recipients
- `HasLocalePreference` on notifiable models
- `assertQueued()` not `assertSent()` for queued mailables
- Markdown mailables for transactional emails
### 13. Error Handling → `rules/error-handling.md`
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
- `ShouldntReport` for exceptions that should never log
- Throttle high-volume exceptions to protect log sinks
- `dontReportDuplicates()` for multi-catch scenarios
- Force JSON rendering for API routes
- Structured context via `context()` on exception classes
### 14. Task Scheduling → `rules/scheduling.md`
- `withoutOverlapping()` on variable-duration tasks
- `onOneServer()` on multi-server deployments
- `runInBackground()` for concurrent long tasks
- `environments()` to restrict to appropriate environments
- `takeUntilTimeout()` for time-bounded processing
- Schedule groups for shared configuration
### 15. Architecture → `rules/architecture.md`
- Single-purpose Action classes; dependency injection over `app()` helper
- Prefer official Laravel packages and follow conventions, don't override defaults
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
### 16. Migrations → `rules/migrations.md`
- Generate migrations with `php artisan make:migration`
- `constrained()` for foreign keys
- Never modify migrations that have run in production
- Add indexes in the migration, not as an afterthought
- Mirror column defaults in model `$attributes`
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
- One concern per migration — never mix DDL and DML
### 17. Collections → `rules/collections.md`
- Higher-order messages for simple collection operations
- `cursor()` vs. `lazy()` — choose based on relationship needs
- `lazyById()` when updating records while iterating
- `toQuery()` for bulk operations on collections
### 18. Blade & Views → `rules/blade-views.md`
- `$attributes->merge()` in component templates
- Blade components over `@include`; `@pushOnce` for per-component scripts
- View Composers for shared view data
- `@aware` for deeply nested component props
### 19. Conventions & Style → `rules/style.md`
- Follow Laravel naming conventions for all entities
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
- No JS/CSS in Blade, no HTML in PHP classes
- Code should be readable; comments only for config files
## How to Apply
Always use a sub-agent to read rule files and explore this skill's content.
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
2. Check sibling files for existing patterns — follow those first per Consistency First
3. Verify API syntax with `search-docs` for the installed Laravel version

View file

@ -0,0 +1,106 @@
# Advanced Query Patterns
## Use `addSelect()` Subqueries for Single Values from Has-Many
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
```php
public function scopeWithLastLoginAt($query): void
{
$query->addSelect([
'last_login_at' => Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->withCasts(['last_login_at' => 'datetime']);
}
```
## Create Dynamic Relationships via Subquery FK
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
```php
public function lastLogin(): BelongsTo
{
return $this->belongsTo(Login::class);
}
public function scopeWithLastLogin($query): void
{
$query->addSelect([
'last_login_id' => Login::select('id')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->with('lastLogin');
}
```
## Use Conditional Aggregates Instead of Multiple Count Queries
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
```php
$statuses = Feature::toBase()
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
->first();
```
## Use `setRelation()` to Prevent Circular N+1
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
```php
$feature->load('comments.user');
$feature->comments->each->setRelation('feature', $feature);
```
## Prefer `whereIn` + Subquery Over `whereHas`
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
Incorrect (correlated EXISTS re-executes per row):
```php
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
```
Correct (index-friendly subquery, no PHP memory overhead):
```php
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
```
## Sometimes Two Simple Queries Beat One Complex Query
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
## Use Compound Indexes Matching `orderBy` Column Order
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
```php
// Migration
$table->index(['last_name', 'first_name']);
// Query — column order must match the index
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
```
## Use Correlated Subqueries for Has-Many Ordering
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
```php
public function scopeOrderByLastLogin($query): void
{
$query->orderByDesc(Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1)
);
}
```

View file

@ -0,0 +1,202 @@
# Architecture Best Practices
## Single-Purpose Action Classes
Extract discrete business operations into invokable Action classes.
```php
class CreateOrderAction
{
public function __construct(private InventoryService $inventory) {}
public function execute(array $data): Order
{
$order = Order::create($data);
$this->inventory->reserve($order);
return $order;
}
}
```
## Use Dependency Injection
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
Incorrect:
```php
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$service = app(OrderService::class);
return $service->create($request->validated());
}
}
```
Correct:
```php
class OrderController extends Controller
{
public function __construct(private OrderService $service) {}
public function store(StoreOrderRequest $request)
{
return $this->service->create($request->validated());
}
}
```
## Code to Interfaces
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
Incorrect (concrete dependency):
```php
class OrderService
{
public function __construct(private StripeGateway $gateway) {}
}
```
Correct (interface dependency):
```php
interface PaymentGateway
{
public function charge(int $amount, string $customerId): PaymentResult;
}
class OrderService
{
public function __construct(private PaymentGateway $gateway) {}
}
```
Bind in a service provider:
```php
$this->app->bind(PaymentGateway::class, StripeGateway::class);
```
## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect:
```php
$posts = Post::paginate();
```
Correct:
```php
$posts = Post::latest()->paginate();
```
## Use Atomic Locks for Race Conditions
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
```php
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
$order->process();
});
// Or at query level
$product = Product::where('id', $id)->lockForUpdate()->first();
```
## Use `mb_*` String Functions
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
Incorrect:
```php
strlen('José'); // 5 (bytes, not characters)
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
```
Correct:
```php
mb_strlen('José'); // 4 (characters)
mb_strtolower('MÜNCHEN'); // 'münchen'
// Prefer Laravel's Str helpers when available
Str::length('José'); // 4
Str::lower('MÜNCHEN'); // 'münchen'
```
## Use `defer()` for Post-Response Work
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
Incorrect (job overhead for trivial work):
```php
dispatch(new LogPageView($page));
```
Correct (runs after response, same process):
```php
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
```
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
## Use `Context` for Request-Scoped Data
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
```php
// In middleware
Context::add('tenant_id', $request->header('X-Tenant-ID'));
// Anywhere later — controllers, jobs, log context
$tenantId = Context::get('tenant_id');
```
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
## Use `Concurrency::run()` for Parallel Execution
Run independent operations in parallel using child processes — no async libraries needed.
```php
use Illuminate\Support\Facades\Concurrency;
[$users, $orders] = Concurrency::run([
fn () => User::count(),
fn () => Order::where('status', 'pending')->count(),
]);
```
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
## Convention Over Configuration
Follow Laravel conventions. Don't override defaults unnecessarily.
Incorrect:
```php
class Customer extends Model
{
protected $table = 'Customer';
protected $primaryKey = 'customer_id';
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
}
}
```
Correct:
```php
class Customer extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}
```

View file

@ -0,0 +1,36 @@
# Blade & Views Best Practices
## Use `$attributes->merge()` in Component Templates
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
```blade
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $message }}
</div>
```
## Use `@pushOnce` for Per-Component Scripts
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
## Prefer Blade Components Over `@include`
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
## Use View Composers for Shared View Data
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
A single view can return either the full page or just a fragment, keeping routing clean.
```php
return view('dashboard', compact('users'))
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
```
## Use `@aware` for Deeply Nested Component Props
Avoids re-passing parent props through every level of nested components.

View file

@ -0,0 +1,70 @@
# Caching Best Practices
## Use `Cache::remember()` Instead of Manual Get/Put
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect:
```php
$val = Cache::get('stats');
if (! $val) {
$val = $this->computeStats();
Cache::put('stats', $val, 60);
}
```
Correct:
```php
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
```
## Use `Cache::flexible()` for Stale-While-Revalidate
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
## Use Cache Tags to Invalidate Related Groups
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
```php
Cache::tags(['user-1'])->flush();
```
## Use `Cache::add()` for Atomic Conditional Writes
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
Correct: `Cache::add('lock', true, 10);`
## Use `once()` for Per-Request Memoization
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
```php
public function roles(): Collection
{
return once(fn () => $this->loadRoles());
}
```
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
## Configure Failover Cache Stores in Production
If Redis goes down, the app falls back to a secondary store automatically.
```php
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
```

View file

@ -0,0 +1,44 @@
# Collection Best Practices
## Use Higher-Order Messages for Simple Operations
Incorrect:
```php
$users->each(function (User $user) {
$user->markAsVip();
});
```
Correct: `$users->each->markAsVip();`
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
## Choose `cursor()` vs. `lazy()` Correctly
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
## Use `lazyById()` When Updating Records While Iterating
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
## Use `toQuery()` for Bulk Operations on Collections
Avoids manual `whereIn` construction.
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
Correct: `$users->toQuery()->update([...]);`
## Use `#[CollectedBy]` for Custom Collection Classes
More declarative than overriding `newCollection()`.
```php
#[CollectedBy(UserCollection::class)]
class User extends Model {}
```

View file

@ -0,0 +1,73 @@
# Configuration Best Practices
## `env()` Only in Config Files
Direct `env()` calls may return `null` when config is cached.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'key' => env('API_KEY'),
// Application code
$key = config('services.key');
```
## Use Encrypted Env or External Secrets
Never store production secrets in plain `.env` files in version control.
Incorrect:
```bash
# .env committed to repo or shared in Slack
STRIPE_SECRET=sk_live_abc123
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
```
Correct:
```bash
php artisan env:encrypt --env=production --readable
php artisan env:decrypt --env=production
```
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
## Use `App::environment()` for Environment Checks
Incorrect:
```php
if (env('APP_ENV') === 'production') {
```
Correct:
```php
if (app()->isProduction()) {
// or
if (App::environment('production')) {
```
## Use Constants and Language Files
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
```php
// Incorrect
return $this->type === 'normal';
// Correct
return $this->type === self::TYPE_NORMAL;
```
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
```php
// Only when lang files already exist in the project
return back()->with('message', __('app.article_added'));
```

View file

@ -0,0 +1,192 @@
# Database Performance Best Practices
## Always Eager Load Relationships
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
Incorrect (N+1 — executes 1 + N queries):
```php
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
```
Correct (2 queries total):
```php
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name;
}
```
Constrain eager loads to select only needed columns (always include the foreign key):
```php
$users = User::with(['posts' => function ($query) {
$query->select('id', 'user_id', 'title')
->where('published', true)
->latest()
->limit(10);
}])->get();
```
## Prevent Lazy Loading in Development
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
```php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
```
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
## Select Only Needed Columns
Avoid `SELECT *` — especially when tables have large text or JSON columns.
Incorrect:
```php
$posts = Post::with('author')->get();
```
Correct:
```php
$posts = Post::select('id', 'title', 'user_id', 'created_at')
->with(['author:id,name,avatar'])
->get();
```
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
## Chunk Large Datasets
Never load thousands of records at once. Use chunking for batch processing.
Incorrect:
```php
$users = User::all();
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
```
Correct:
```php
User::where('subscribed', true)->chunk(200, function ($users) {
foreach ($users as $user) {
$user->notify(new WeeklyDigest);
}
});
```
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
```php
User::where('active', false)->chunkById(200, function ($users) {
$users->each->delete();
});
```
## Add Database Indexes
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
```
Correct:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->index()->constrained();
$table->string('status')->index();
$table->timestamps();
$table->index(['status', 'created_at']);
});
```
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
## Use `withCount()` for Counting Relations
Never load entire collections just to count them.
Incorrect:
```php
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count();
}
```
Correct:
```php
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
```
Conditional counting:
```php
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => function ($query) {
$query->where('approved', true);
},
])->get();
```
## Use `cursor()` for Memory-Efficient Iteration
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
Incorrect:
```php
$users = User::where('active', true)->get();
```
Correct:
```php
foreach (User::where('active', true)->cursor() as $user) {
ProcessUser::dispatch($user->id);
}
```
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
## No Queries in Blade Templates
Never execute queries in Blade templates. Pass data from controllers.
Incorrect:
```blade
@foreach (User::all() as $user)
{{ $user->profile->name }}
@endforeach
```
Correct:
```php
// Controller
$users = User::with('profile')->get();
return view('users.index', compact('users'));
```
```blade
@foreach ($users as $user)
{{ $user->profile->name }}
@endforeach
```

View file

@ -0,0 +1,148 @@
# Eloquent Best Practices
## Use Correct Relationship Types
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
```php
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
```
## Use Local Scopes for Reusable Queries
Extract reusable query constraints into local scopes to avoid duplication.
Incorrect:
```php
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
$articles = Article::whereHas('user', function ($q) {
$q->where('verified', true)->whereNotNull('activated_at');
})->get();
```
Correct:
```php
public function scopeActive(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
// Usage
$active = User::active()->get();
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
```
## Apply Global Scopes Sparingly
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
Incorrect (global scope for a conditional filter):
```php
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('published', true);
}
}
// Now admin panels, reports, and background jobs all silently skip drafts
```
Correct (local scope you opt into):
```php
public function scopePublished(Builder $query): Builder
{
return $query->where('published', true);
}
Post::published()->paginate(); // Explicit
Post::paginate(); // Admin sees all
```
## Define Attribute Casts
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
```php
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'total' => 'decimal:2',
];
}
```
## Cast Date Columns Properly
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
Incorrect:
```blade
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
```
Correct:
```php
protected function casts(): array
{
return [
'ordered_at' => 'datetime',
];
}
```
```blade
{{ $order->ordered_at->toDateString() }}
{{ $order->ordered_at->format('m-d') }}
```
## Use `whereBelongsTo()` for Relationship Queries
Cleaner than manually specifying foreign keys.
Incorrect:
```php
Post::where('user_id', $user->id)->get();
```
Correct:
```php
Post::whereBelongsTo($user)->get();
Post::whereBelongsTo($user, 'author')->get();
```
## Avoid Hardcoded Table Names in Queries
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
Incorrect:
```php
DB::table('users')->where('active', true)->get();
$query->join('companies', 'companies.id', '=', 'users.company_id');
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
```
Correct — reference the model's table:
```php
DB::table((new User)->getTable())->where('active', true)->get();
// Even better — use Eloquent or the query builder instead of raw SQL
User::where('active', true)->get();
Order::where('status', 'pending')->get();
```
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.

View file

@ -0,0 +1,72 @@
# Error Handling Best Practices
## Exception Reporting and Rendering
There are two valid approaches — choose one and apply it consistently across the project.
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
```php
class InvalidOrderException extends Exception
{
public function report(): void { /* custom reporting */ }
public function render(Request $request): Response
{
return response()->view('errors.invalid-order', status: 422);
}
}
```
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
```php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
$exceptions->render(function (InvalidOrderException $e, Request $request) {
return response()->view('errors.invalid-order', status: 422);
});
})
```
Check the existing codebase and follow whichever pattern is already established.
## Use `ShouldntReport` for Exceptions That Should Never Log
More discoverable than listing classes in `dontReport()`.
```php
class PodcastProcessingException extends Exception implements ShouldntReport {}
```
## Throttle High-Volume Exceptions
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
## Enable `dontReportDuplicates()`
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
## Force JSON Error Rendering for API Routes
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
```php
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
return $request->is('api/*') || $request->expectsJson();
});
```
## Add Context to Exception Classes
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
```php
class InvalidOrderException extends Exception
{
public function context(): array
{
return ['order_id' => $this->orderId];
}
}
```

View file

@ -0,0 +1,52 @@
# Events & Notifications Best Practices
## Rely on Event Discovery
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
## Run `event:cache` in Production Deploy
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
## Use `ShouldDispatchAfterCommit` Inside Transactions
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
```php
class OrderShipped implements ShouldDispatchAfterCommit {}
```
## Always Queue Notifications
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
```php
class InvoicePaid extends Notification implements ShouldQueue
{
use Queueable;
}
```
## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
## Use On-Demand Notifications for Non-User Recipients
Avoid creating dummy models to send notifications to arbitrary addresses.
```php
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
```
## Implement `HasLocalePreference` on Notifiable Models
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.

View file

@ -0,0 +1,160 @@
# HTTP Client Best Practices
## Always Set Explicit Timeouts
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
Incorrect:
```php
$response = Http::get('https://api.example.com/users');
```
Correct:
```php
$response = Http::timeout(5)
->connectTimeout(3)
->get('https://api.example.com/users');
```
For service-specific clients, define timeouts in a macro:
```php
Http::macro('github', function () {
return Http::baseUrl('https://api.github.com')
->timeout(10)
->connectTimeout(3)
->withToken(config('services.github.token'));
});
$response = Http::github()->get('/repos/laravel/framework');
```
## Use Retry with Backoff for External APIs
External APIs have transient failures. Use `retry()` with increasing delays.
Incorrect:
```php
$response = Http::post('https://api.stripe.com/v1/charges', $data);
if ($response->failed()) {
throw new PaymentFailedException('Charge failed');
}
```
Correct:
```php
$response = Http::retry([100, 500, 1000])
->timeout(10)
->post('https://api.stripe.com/v1/charges', $data);
```
Only retry on specific errors:
```php
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data');
```
## Handle Errors Explicitly
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
Incorrect:
```php
$response = Http::get('https://api.example.com/users/1');
$user = $response->json(); // Could be an error body
```
Correct:
```php
$response = Http::timeout(5)
->get('https://api.example.com/users/1')
->throw();
$user = $response->json();
```
For graceful degradation:
```php
$response = Http::get('https://api.example.com/users/1');
if ($response->successful()) {
return $response->json();
}
if ($response->notFound()) {
return null;
}
$response->throw();
```
## Use Request Pooling for Concurrent Requests
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
Incorrect:
```php
$users = Http::get('https://api.example.com/users')->json();
$posts = Http::get('https://api.example.com/posts')->json();
$comments = Http::get('https://api.example.com/comments')->json();
```
Correct:
```php
use Illuminate\Http\Client\Pool;
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('users')->get('https://api.example.com/users'),
$pool->as('posts')->get('https://api.example.com/posts'),
$pool->as('comments')->get('https://api.example.com/comments'),
]);
$users = $responses['users']->json();
$posts = $responses['posts']->json();
```
## Fake HTTP Calls in Tests
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
Incorrect:
```php
it('syncs user from API', function () {
$service = new UserSyncService;
$service->sync(1); // Hits the real API
});
```
Correct:
```php
it('syncs user from API', function () {
Http::preventStrayRequests();
Http::fake([
'api.example.com/users/1' => Http::response([
'name' => 'John Doe',
'email' => 'john@example.com',
]),
]);
$service = new UserSyncService;
$service->sync(1);
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.example.com/users/1';
});
});
```
Test failure scenarios too:
```php
Http::fake([
'api.example.com/*' => Http::failedConnection(),
]);
```

View file

@ -0,0 +1,27 @@
# Mail Best Practices
## Implement `ShouldQueue` on the Mailable Class
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
## Use `afterCommit()` on Mailables Inside Transactions
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
Correct: `Mail::assertQueued(OrderShipped::class);`
## Use Markdown Mailables for Transactional Emails
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
## Separate Content Tests from Sending Tests
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
Don't mix them — it conflates concerns and makes tests brittle.

View file

@ -0,0 +1,121 @@
# Migration Best Practices
## Generate Migrations with Artisan
Always use `php artisan make:migration` for consistent naming and timestamps.
Incorrect (manually created file):
```php
// database/migrations/posts_migration.php ← wrong naming, no timestamp
```
Correct (Artisan-generated):
```bash
php artisan make:migration create_posts_table
php artisan make:migration add_slug_to_posts_table
```
## Use `constrained()` for Foreign Keys
Automatic naming and referential integrity.
```php
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Non-standard names
$table->foreignId('author_id')->constrained('users');
```
## Never Modify Deployed Migrations
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
Incorrect (editing a deployed migration):
```php
// 2024_01_01_create_posts_table.php — already in production
$table->string('slug')->unique(); // ← added after deployment
```
Correct (new migration to alter):
```php
// 2024_03_15_add_slug_to_posts_table.php
Schema::table('posts', function (Blueprint $table) {
$table->string('slug')->unique()->after('title');
});
```
## Add Indexes in the Migration
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
Incorrect:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('status');
$table->timestamps();
});
```
Correct:
```php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->index();
$table->string('status')->index();
$table->timestamp('shipped_at')->nullable()->index();
$table->timestamps();
});
```
## Mirror Defaults in Model `$attributes`
When a column has a database default, mirror it in the model so new instances have correct values before saving.
```php
// Migration
$table->string('status')->default('pending');
// Model
protected $attributes = [
'status' => 'pending',
];
```
## Write Reversible `down()` Methods by Default
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
```php
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
```
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
## Keep Migrations Focused
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
Incorrect (partial failure creates unrecoverable state):
```php
public function up(): void
{
Schema::create('settings', function (Blueprint $table) { ... });
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
}
```
Correct (separate migrations):
```php
// Migration 1: create_settings_table
Schema::create('settings', function (Blueprint $table) { ... });
// Migration 2: seed_default_settings
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
```

View file

@ -0,0 +1,144 @@
# Queue & Job Best Practices
## Set `retry_after` Greater Than `timeout`
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
Incorrect (`retry_after` ≤ `timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
// config/queue.php — retry_after: 90 ← job retried while still running!
```
Correct (`retry_after` > `timeout`):
```php
class ProcessReport implements ShouldQueue
{
public $timeout = 120;
}
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
```
## Use Exponential Backoff
Use progressively longer delays between retries to avoid hammering failing services.
Incorrect (fixed retry interval):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
// Default: retries immediately, overwhelming the API
}
```
Correct (exponential backoff):
```php
class SyncWithStripe implements ShouldQueue
{
public $tries = 3;
public $backoff = [1, 5, 10];
}
```
## Implement `ShouldBeUnique`
Prevent duplicate job processing.
```php
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return $this->order->id;
}
public $uniqueFor = 3600;
}
```
## Always Implement `failed()`
Handle errors explicitly — don't rely on silent failure.
```php
public function failed(?Throwable $exception): void
{
$this->podcast->update(['status' => 'failed']);
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
}
```
## Rate Limit External API Calls in Jobs
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
```php
public function middleware(): array
{
return [new RateLimited('external-api')];
}
```
## Batch Related Jobs
Use `Bus::batch()` when jobs should succeed or fail together.
```php
Bus::batch([
new ImportCsvChunk($chunk1),
new ImportCsvChunk($chunk2),
])
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
->dispatch();
```
## `retryUntil()` Needs `$tries = 0`
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
```php
public $tries = 0;
public function retryUntil(): \DateTimeInterface
{
return now()->addHours(4);
}
```
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// Lock releases when processing begins, not when it finishes
}
```
## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
```php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
],
],
],
```

View file

@ -0,0 +1,99 @@
# Routing & Controllers Best Practices
## Use Implicit Route Model Binding
Let Laravel resolve models automatically from route parameters.
Incorrect:
```php
public function show(int $id)
{
$post = Post::findOrFail($id);
}
```
Correct:
```php
public function show(Post $post)
{
return view('posts.show', ['post' => $post]);
}
```
## Use Scoped Bindings for Nested Resources
Enforce parent-child relationships automatically.
```php
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
// $post is automatically scoped to $user
})->scopeBindings();
```
## Use Resource Controllers
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
```php
Route::resource('posts', PostController::class);
// In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
```
## Keep Controllers Thin
Aim for under 10 lines per method. Extract business logic to action or service classes.
Incorrect:
```php
public function store(Request $request)
{
$validated = $request->validate([...]);
if ($request->hasFile('image')) {
$request->file('image')->move(public_path('images'));
}
$post = Post::create($validated);
$post->tags()->sync($validated['tags']);
event(new PostCreated($post));
return redirect()->route('posts.show', $post);
}
```
Correct:
```php
public function store(StorePostRequest $request, CreatePostAction $create)
{
$post = $create->execute($request->validated());
return redirect()->route('posts.show', $post);
}
```
## Type-Hint Form Requests
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
Incorrect:
```php
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'max:255'],
'body' => ['required'],
]);
Post::create($validated);
return redirect()->route('posts.index');
}
```
Correct:
```php
public function store(StorePostRequest $request): RedirectResponse
{
Post::create($request->validated());
return redirect()->route('posts.index');
}
```

View file

@ -0,0 +1,39 @@
# Task Scheduling Best Practices
## Use `withoutOverlapping()` on Variable-Duration Tasks
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
## Use `onOneServer()` on Multi-Server Deployments
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
## Use `runInBackground()` for Concurrent Long Tasks
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
## Use `environments()` to Restrict Tasks
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
```php
Schedule::command('billing:charge')->monthly()->environments(['production']);
```
## Use `takeUntilTimeout()` for Time-Bounded Processing
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
## Use Schedule Groups for Shared Configuration
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
```php
Schedule::daily()
->onOneServer()
->timezone('America/New_York')
->group(function () {
Schedule::command('emails:send --force');
Schedule::command('emails:prune');
});
```

View file

@ -0,0 +1,198 @@
# Security Best Practices
## Mass Assignment Protection
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
Incorrect:
```php
class User extends Model
{
protected $guarded = []; // All fields are mass assignable
}
```
Correct:
```php
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}
```
Never use `$guarded = []` on models that accept user input.
## Authorize Every Action
Use policies or gates in controllers. Never skip authorization.
Incorrect:
```php
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
}
```
Correct:
```php
public function update(UpdatePostRequest $request, Post $post)
{
Gate::authorize('update', $post);
$post->update($request->validated());
}
```
Or via Form Request:
```php
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
```
## Prevent SQL Injection
Always use parameter binding. Never interpolate user input into queries.
Incorrect:
```php
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
```
Correct:
```php
User::where('name', $request->name)->get();
// Raw expressions with bindings
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
```
## Escape Output to Prevent XSS
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
Incorrect:
```blade
{!! $user->bio !!}
```
Correct:
```blade
{{ $user->bio }}
```
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Incorrect:
```blade
<form method="POST" action="/posts">
<input type="text" name="title">
</form>
```
Correct:
```blade
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
</form>
```
## Rate Limit Auth and API Routes
Apply `throttle` middleware to authentication and API routes.
```php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Route::post('/login', LoginController::class)->middleware('throttle:login');
```
## Validate File Uploads
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php
public function rules(): array
{
return [
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
}
```
Store with generated filenames:
```php
$path = $request->file('avatar')->store('avatars', 'public');
```
## Keep Secrets Out of Code
Never commit `.env`. Access secrets via `config()` only.
Incorrect:
```php
$key = env('API_KEY');
```
Correct:
```php
// config/services.php
'api_key' => env('API_KEY'),
// In application code
$key = config('services.api_key');
```
## Audit Dependencies
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
```bash
composer audit
```
## Encrypt Sensitive Database Fields
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
Incorrect:
```php
class Integration extends Model
{
protected function casts(): array
{
return [
'api_key' => 'string',
];
}
}
```
Correct:
```php
class Integration extends Model
{
protected $hidden = ['api_key', 'api_secret'];
protected function casts(): array
{
return [
'api_key' => 'encrypted',
'api_secret' => 'encrypted',
];
}
}
```

View file

@ -0,0 +1,125 @@
# Conventions & Style
## Follow Laravel Naming Conventions
| What | Convention | Good | Bad |
|------|-----------|------|-----|
| Controller | singular | `ArticleController` | `ArticlesController` |
| Model | singular | `User` | `Users` |
| Table | plural, snake_case | `article_comments` | `articleComments` |
| Pivot table | singular alphabetical | `article_user` | `user_article` |
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
| Route | plural | `articles/1` | `article/1` |
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
| Method | camelCase | `getAll` | `get_all` |
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
| Collection | descriptive, plural | `$activeUsers` | `$data` |
| Object | descriptive, singular | `$activeUser` | `$users` |
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
| Enum | singular | `UserType` | `UserTypes` |
## Prefer Shorter Readable Syntax
| Verbose | Shorter |
|---------|---------|
| `Session::get('cart')` | `session('cart')` |
| `$request->session()->get('cart')` | `session('cart')` |
| `$request->input('name')` | `$request->name` |
| `return Redirect::back()` | `return back()` |
| `Carbon::now()` | `now()` |
| `App::make('Class')` | `app('Class')` |
| `->where('column', '=', 1)` | `->where('column', 1)` |
| `->orderBy('created_at', 'desc')` | `->latest()` |
| `->orderBy('created_at', 'asc')` | `->oldest()` |
| `->first()->name` | `->value('name')` |
## Use Laravel String & Array Helpers
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
Strings — use `Str` and fluent `Str::of()` over raw PHP:
```php
// Incorrect
$slug = strtolower(str_replace(' ', '-', $title));
$short = substr($text, 0, 100) . '...';
$class = substr(strrchr('App\Models\User', '\'), 1);
// Correct
$slug = Str::slug($title);
$short = Str::limit($text, 100);
$class = class_basename('App\Models\User');
```
Fluent strings — chain operations for complex transformations:
```php
// Incorrect
$result = strtolower(trim(str_replace('_', '-', $input)));
// Correct
$result = Str::of($input)->trim()->replace('_', '-')->lower();
```
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
Arrays — use `Arr` over raw PHP:
```php
// Incorrect
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
// Correct
$name = Arr::get($array, 'user.name', 'default');
```
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
Numbers — use `Number` for display formatting:
```php
Number::format(1000000); // "1,000,000"
Number::currency(1500, 'USD'); // "$1,500.00"
Number::abbreviate(1000000); // "1M"
Number::fileSize(1024 * 1024); // "1 MB"
Number::percentage(75.5); // "75.5%"
```
URIs — use `Uri` for URL manipulation:
```php
$uri = Uri::of('https://example.com/search')
->withQuery(['q' => 'laravel', 'page' => 1]);
```
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
Use `search-docs` for the full list of available methods — these helpers are extensive.
## No Inline JS/CSS in Blade
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
Incorrect:
```blade
let article = `{{ json_encode($article) }}`;
```
Correct:
```blade
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
```
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
## No Unnecessary Comments
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
Incorrect:
```php
// Check if there are any joins
if (count((array) $builder->getQuery()->joins) > 0)
```
Correct:
```php
if ($this->hasJoins())
```

View file

@ -0,0 +1,43 @@
# Testing Best Practices
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
Correct: `$this->assertModelExists($user);`
More expressive, type-safe, and fails with clearer messages.
## Use Factory States and Sequences
Named states make tests self-documenting. Sequences eliminate repetitive setup.
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
Correct: `User::factory()->unverified()->create();`
## Use `Exceptions::fake()` to Assert Exception Reporting
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
## Call `Event::fake()` After Factory Setup
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
Incorrect: `Event::fake(); $user = User::factory()->create();`
Correct: `$user = User::factory()->create(); Event::fake();`
## Use `recycle()` to Share Relationship Instances Across Factories
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
```php
Ticket::factory()
->recycle(Airline::factory()->create())
->create();
```

View file

@ -0,0 +1,75 @@
# Validation & Forms Best Practices
## Use Form Request Classes
Extract validation from controllers into dedicated Form Request classes.
Incorrect:
```php
public function store(Request $request)
{
$request->validate([
'title' => 'required|max:255',
'body' => 'required',
]);
}
```
Correct:
```php
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}
```
## Array vs. String Notation for Rules
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
```php
// Preferred for new code
'email' => ['required', 'email', Rule::unique('users')],
// Follow existing convention if the project uses string notation
'email' => 'required|email|unique:users',
```
## Always Use `validated()`
Get only validated data. Never use `$request->all()` for mass operations.
Incorrect:
```php
Post::create($request->all());
```
Correct:
```php
Post::create($request->validated());
```
## Use `Rule::when()` for Conditional Validation
```php
'company_name' => [
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
],
```
## Use the `after()` Method for Custom Validation
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
```php
public function after(): array
{
return [
function (Validator $validator) {
if ($this->quantity > Product::find($this->product_id)?->stock) {
$validator->errors()->add('quantity', 'Not enough stock.');
}
},
];
}
```

View file

@ -0,0 +1,110 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
---
# Pest Testing 3
## Documentation
Use `search-docs` for detailed Pest 3 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
### Test Organization
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Do NOT remove tests without approval - these are core application code.
- Test happy paths, failure paths, and edge cases.
### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
<!-- Basic Pest Test Example -->
```php
it('is true', function () {
expect(true)->toBeTrue();
});
```
### Running Tests
- Run minimal tests with filter before finalizing: `vendor/bin/sail artisan test --compact --filter=testName`.
- Run all tests: `vendor/bin/sail artisan test --compact`.
- Run file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
<!-- Pest Response Assertion -->
```php
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
```
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
<!-- Pest Dataset Example -->
```php
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
```
## Pest 3 Features
### Architecture Testing
Pest 3 includes architecture testing to enforce code conventions:
<!-- Architecture Test Example -->
```php
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
arch('models')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
arch('no debugging')
->expect(['dd', 'dump', 'ray'])
->not->toBeUsed();
```
### Type Coverage
Pest 3 provides improved type coverage analysis. Run with `--type-coverage` flag.
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval

View file

@ -0,0 +1,119 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<!-- CSS-First Config -->
```css
@theme {
--color-brand: oklch(0.72 0.11 178);
}
```
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<!-- v4 Import Syntax -->
```diff
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
```
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View file

@ -0,0 +1,88 @@
---
name: volt-development
description: "Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files."
license: MIT
metadata:
author: laravel
---
# Volt Development
## Documentation
Use `search-docs` for detailed Volt patterns and documentation.
## Basic Usage
Create components with `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`.
Important: Check existing Volt components to determine if they use functional or class-based style before creating new ones.
### Functional Components
<!-- Volt Functional Component -->
```php
@@volt
<?php
use function Livewire\Volt\{state, computed};
state(['count' => 0]);
$increment = fn () => $this->count++;
$double = computed(fn () => $this->count * 2);
?>
<div>
<h1>Count: @{{ $count }} (Double: @{{ $this->double }})</h1>
<button wire:click="increment">+</button>
</div>
@@endvolt
```
### Class-Based Components
<!-- Volt Class-based Component -->
```php
use Livewire\Volt\Component;
new class extends Component {
public int $count = 0;
public function increment(): void
{
$this->count++;
}
} ?>
<div>
<h1>@{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
```
## Testing
Tests go in existing Volt test directory or `tests/Feature/Volt`:
<!-- Volt Test Example -->
```php
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
```
## Verification
1. Check existing components for functional vs class-based style
2. Test component with `Volt::test()`
## Common Pitfalls
- Not checking existing style (functional vs class-based) before creating
- Forgetting `@volt` directive wrapper
- Missing `--test` or `--pest` flag when tests are needed

22
.codex/config.toml Normal file
View file

@ -0,0 +1,22 @@
[mcp_servers.context7]
args = [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9",
]
command = "npx"
[mcp_servers.playwright]
url = "https://playwright.test/mcp"
[mcp_servers.sequential-thinking]
args = [
"-y",
"@modelcontextprotocol/server-sequential-thinking",
]
command = "npx"
[mcp_servers.laravel-boost]
command = "php"
args = ["artisan", "boost:mcp"]
cwd = "/var/www/html"

15
.cursorrules Normal file
View file

@ -0,0 +1,15 @@
# Cursor Rules: Laravel 12 CRM & Presse-Portal
## Tech Stack & Paradigmen
- **Framework:** Laravel 12 (PHP 8.4+)
- **Frontend-Architektur:** Livewire Volt (ausschließlich Functional API).
- **UI-Komponenten:** FluxUI (nutze `<flux:...>` statt Standard-Blade/HTML).
- **Styling:** Tailwind CSS (für Layout) & FluxUI (für Komponenten).
- **Backend:** Klassisches MVC für das Admin-System, Services für Business-Logik.
## Documentation & Sources
- **Projekt-Workflow (Dokumentation in `docs/`, Forgejo `tea`, Git/Conventional Commits):** `docs/KI-UND-ENTWICKLER-WORKFLOW.md`
- **FluxUI:** Nutze primär die indizierte Dokumentation unter @FluxUI (falls vorhanden).
- **Verhaltensregel:** Wenn du dir bei einer FluxUI-Komponente unsicher bist (da sich die API oft ändert), frage mich oder nutze das @Docs Tool, anstatt zu raten.
- **Wichtige Syntax-Note:** FluxUI nutzt oft spezifische Attribute (wie `variant="subtle"` oder `icon-trailing`). Bevorzuge immer die Syntax der neuesten Version 2.x.

View file

@ -47,7 +47,7 @@ Das Dockerfile wurde angepasst um:
Falls die automatische Installation fehlschlägt, können Sie den Container manuell bauen:
```bash
cd /Users/pandora/Sites/pr-copilot.eu
cd /Users/pandora/Sites/pressekonto.de
docker build --build-arg WWWUSER=501 --build-arg WWWGROUP=20 -f docker/8.4/Dockerfile -t sail-8.4/app docker/8.4
```

1
.devcontainer/Untitled Normal file
View file

@ -0,0 +1 @@
.devcontainer

View file

@ -1,63 +1,45 @@
{
"name": "PR-Copilot (Dev Container)",
// 1. DIES IST DER WICHTIGSTE TEIL:
// Wir verwenden Docker Compose für alle Services
"name": "Pressekonto (Dev Container)",
"dockerComposeFile": [
"../docker-compose.yml"
],
"service": "laravel.test",
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
// Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt.
"workspaceFolder": "/var/www/html",
// 4. WIR LEGEN DEN BENUTZER FEST:
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
"remoteUser": "sail",
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
"features": {},
// 6. BEFEHLE NACH DEM ERSTELLEN:
// Installiert nur die Tools die ohne Root-Rechte funktionieren
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
"customizations": {
"vscode": {
"extensions": [
"bmewburn.vscode-intelephense-client",
"onecentlin.laravel-blade",
"shufo.vscode-blade-formatter",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"Anthropic.claude-code",
"adrianwilczynski.alpine-js-intellisense",
"onecentlin.laravel-extension-pack",
"cierra.livewire-vscode"
]
}
},
// 8. ZU STARTENDE DIENSTE:
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
// WICHTIG: Nur noch der Haupt-Container bleibt drin
"runServices": [
"laravel.test",
"mysql",
"redis",
"mailpit"
"laravel.test"
],
// 9. ZUSÄTZLICHE KONFIGURATION:
// Umgebungsvariablen für den DevContainer
"containerEnv": {
"WWWUSER": "501",
"WWWGROUP": "20",
"LARAVEL_SAIL": "1"
},
// 10. MOUNT-KONFIGURATION:
// Stellt sicher, dass der Code korrekt gemountet wird
"mounts": [
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached",
"source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/presseportale,target=/var/www/html/docs,type=bind",
"source=/Users/pandora/.forgejo_token,target=/tmp/.forgejo_token,type=bind,readonly",
"source=/Users/pandora/.ssh,target=/home/sail/.ssh,type=bind,readonly"
],
// 11. FORWARD PORTS:
// Ports die automatisch weitergeleitet werden sollen
"postCreateCommand": "mkdir -p ~/.local/bin && curl -L https://gitea.com/gitea/tea/releases/download/v0.14.0/tea-0.14.0-linux-arm64 -o ~/.local/bin/tea && chmod +x ~/.local/bin/tea && echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.bashrc && (~/.local/bin/tea login add --name 'gitmedia' --url 'https://git.adametz.media' --token $(cat /tmp/.forgejo_token) || true)",
"forwardPorts": [
5177,
5178,
33069,
6382,
1027,
8027
5178
],
"portsAttributes": {
"5177": {
@ -67,22 +49,9 @@
"5178": {
"label": "Vite Dev Server (Web)",
"onAutoForward": "notify"
},
"33069": {
"label": "MySQL",
"onAutoForward": "silent"
},
"6382": {
"label": "Redis",
"onAutoForward": "silent"
},
"8027": {
"label": "Mailpit Dashboard",
"onAutoForward": "notify"
},
"1027": {
"label": "Mailpit",
"onAutoForward": "silent"
}
}
},
"runArgs": [
"--network=host"
]
}

View file

@ -1,25 +1,67 @@
APP_NAME=Laravel
APP_NAME=Pressekonto
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=https://pressekonto.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_LOCALE=de
APP_FALLBACK_LOCALE=de
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# HTTP Basic Auth Gate (Staging-Schutz). Nur erzwungen, wenn aktiviert
# UND BASIC_AUTH_USER/BASIC_AUTH_PASSWORD gesetzt sind.
BASIC_AUTH_ENABLED=true
BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD=
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt.
# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook).
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Betriebs-Alerts (R5.3): fehlgeschlagene Scheduler-Tasks/Queue-Jobs werden als
# `critical` geloggt und – wenn aktiviert – zusätzlich an MONITORING_ALERT_EMAIL
# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen.
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
# ---------------------------------------------------------------------------
# Multi-Domain-Setup (config/domains.php)
# Hub: pressekonto (Portal-Backend + Hub-Landing) — Spokes: presseecho,
# businessportal24. Prod: .test durch echte Domains ersetzen
# (pressekonto.de, presseecho.de, businessportal24.com).
# ---------------------------------------------------------------------------
APP_PROTOCOL="https://"
APP_PORTAL_NAME="pressekonto.test"
APP_PORTAL_URL="https://pressekonto.test"
APP_PORTAL_PRIMARY="#526266"
APP_PORTAL_SECONDARY="#82a0a7"
# APP_PORTAL_ASSET_URL=
APP_PRESSEECHO_NAME=presseecho.test
APP_PRESSEECHO_URL=https://presseecho.test
APP_PRESSEECHO_PRIMARY="#345636"
APP_PRESSEECHO_SECONDARY="#6b8f71"
APP_BUSINESSPORTAL_NAME=businessportal24.test
APP_BUSINESSPORTAL_URL=https://businessportal24.test
APP_BUSINESSPORTAL_PRIMARY="#cf3628"
APP_BUSINESSPORTAL_SECONDARY="#f0834a"
# Sanctum: Hosts, die stateful (Session-Cookie) auf die API zugreifen dürfen.
# Prod: echte Domains kommasepariert eintragen.
SANCTUM_STATEFUL_DOMAINS=pressekonto.test,presseecho.test,businessportal24.test
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
@ -27,11 +69,27 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
# ---------------------------------------------------------------------------
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
# ---------------------------------------------------------------------------
DB_HOST_BUSINESSPORTAL=127.0.0.1
DB_DATABASE_BUSINESSPORTAL=
DB_USERNAME_BUSINESSPORTAL=
DB_PASSWORD_BUSINESSPORTAL=
DB_HOST_PRESSEECHO=127.0.0.1
DB_DATABASE_PRESSEECHO=
DB_USERNAME_PRESSEECHO=
DB_PASSWORD_PRESSEECHO=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Prod: true setzen (Cookies nur über HTTPS).
# SESSION_SECURE_COOKIE=true
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@ -47,6 +105,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Prod: echten Mailer konfigurieren — Default `log` versendet NICHTS.
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
@ -63,3 +122,97 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Lokaler Vite-Dev-Server (dual-port Setup, siehe VITE-SETUP.md).
VITE_DEV_SERVER_URL=https://assets.pressekonto.test
# ---------------------------------------------------------------------------
# Stripe / Billing (Laravel Cashier)
# Prod: Live-Keys + Live-Preis-IDs eintragen [EXTERN: R1.2].
# ---------------------------------------------------------------------------
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Stripe-Preis-ID des Einzelkaufs (eine PM, 19 €).
STRIPE_PRICE_SINGLE_PM=
CASHIER_CURRENCY=eur
CASHIER_CURRENCY_LOCALE=de_DE
# Umsatzsteuer/Rechnungsangaben.
# BILLING_VAT_RATE=0.19
# BILLING_DEFAULT_CURRENCY=EUR
# BILLING_MANUAL_DUE_DAYS=14
BILLING_OWN_VAT_ID=
# Abo-/Bestandsschutz-Gate (WS-5). Default (auch ohne Env-Key) ist true:
# Einreichen zur Prüfung erfordert ein aktives Abo, einen bezahlten Einzelkauf
# oder Bestandsschutz (grandfathered) — siehe User::hasActiveBooking().
# Nur für lokale Entwicklung bewusst auf false setzen (Gate offen für alle).
BILLING_ENFORCE_BOOKING=true
# ---------------------------------------------------------------------------
# KI: Klassifikation (Red-Flag-Gate) + Content-Score (config/scoring.php).
# Ohne OPENAI_API_KEY laufen die Jobs nicht — Queue-Worker erforderlich.
# ---------------------------------------------------------------------------
OPENAI_API_KEY=
# OPENAI_API_URL=
# OPENAI_MODEL=
# OPENAI_TIMEOUT=30
# CLASSIFICATION_PROVIDER=openai
# CLASSIFICATION_MODEL=
# CLASSIFICATION_TIMEOUT=15
# CLASSIFICATION_GREEN_DELAY=0
# CLASSIFICATION_YELLOW_MANUAL=true
# CONTENT_SCORE_PROVIDER=openai
# CONTENT_SCORE_MODEL=
# CONTENT_SCORE_TIMEOUT=30
# CONTENT_SCORE_HOCHWERTIG=80
# CONTENT_SCORE_GEPRUEFT=60
# ---------------------------------------------------------------------------
# Newsletter-Sync (config/newsletter.php). Provider `placeholder` bindet den
# Null-Client; enabled=true mit anderem Provider wirft bewusst, bis eine
# echte Anbindung implementiert ist.
# ---------------------------------------------------------------------------
NEWSLETTER_SYNC_ENABLED=false
NEWSLETTER_SYNC_PROVIDER=placeholder
NEWSLETTER_SYNC_ENDPOINT=
NEWSLETTER_SYNC_API_KEY=
# NEWSLETTER_SYNC_TIMEOUT=10
# Google-Login (Laravel Socialite). Die Redirect-URI muss in der Google Cloud
# Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf
# dem Portal-Host, nicht zwingend APP_URL). Prod: https://pressekonto.com/...
# Hinweis: Google akzeptiert keine .test-Domains – lokal nur localhost oder ein
# https-Tunnel; der echte Round-Trip wird auf Staging/Live getestet.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback
# Empfänger des öffentlichen Kontaktformulars je Brand. Ohne Wert fällt der
# Versand auf MAIL_FROM_ADDRESS zurück. [EXTERN: echte Adressen eintragen]
CONTACT_EMAIL_PRESSEKONTO=
CONTACT_EMAIL_PRESSEECHO=
CONTACT_EMAIL_BUSINESSPORTAL=
# Depublikation (T12): Netto-Preis in Cent fuer die freiwillige Entfernung
# einer veroeffentlichten PM aus der oeffentlichen Ansicht (Tombstone bleibt).
# Default 3500 (35 EUR netto, Entscheidung O6 vom 06.07.2026); 0 deaktiviert.
BILLING_DEPUBLICATION_PRICE_CENTS=3500
# Selbst gehostete Umami-Webanalyse (sys.adametz.media; IDs je Brand in
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.
UMAMI_ENABLED=false
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
# Ohne Wert gilt der Default info@pressekonto.com (config/legal.php).
LEGAL_CONTACT_EMAIL=
# Optionales separates DSA-Melde-Postfach je Brand (Impressum + /melden).
# Ohne Wert fällt die Anzeige auf LEGAL_CONTACT_EMAIL zurück.
REPORT_EMAIL_PRESSEKONTO=
REPORT_EMAIL_PRESSEECHO=
REPORT_EMAIL_BUSINESSPORTAL=

11
.gitignore vendored
View file

@ -55,4 +55,13 @@ Icon
# Project specific
_static/
_work/
_storage/
_storage/
_businessportal24.com/
dev/migration/
# Produktivdaten: Legacy-SQL-Dumps bleiben lokal, nie ins Repo (Entscheidung 06.07.2026)
dev/archive/migration-2026/sql/
# Produktivdaten: API-Aktivitaets-Logs der Altportale (Kundendaten + Alt-API-Keys), analog SQL-Dumps
dev/api/
# Rechtstext-Quellen des Users (untracked Arbeitsstand)
docs/legal/

30
.mcp.json Normal file
View file

@ -0,0 +1,30 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
},
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
},
"playwright": {
"url": "https://playwright.test/mcp"
}
}
}

226
AGENTS.md Normal file
View file

@ -0,0 +1,226 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
## Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
## Searching Documentation (IMPORTANT)
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
### Search Syntax
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
- Run Artisan commands directly via the command line (e.g., `vendor/bin/sail artisan route:list`). Use `vendor/bin/sail artisan list` to discover available commands and `vendor/bin/sail artisan [command] --help` to check parameters.
- Inspect routes with `vendor/bin/sail artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `vendor/bin/sail artisan config:show app.name`, `vendor/bin/sail artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== sail rules ===
# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `vendor/bin/sail artisan list` and check their parameters with `vendor/bin/sail artisan [command] --help`.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `vendor/bin/sail artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== volt/core rules ===
# Livewire Volt
- Single-file Livewire components: PHP logic and Blade templates in one file.
- Always check existing Volt components to determine functional vs class-based style.
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
</laravel-boost-guidelines>

233
CLAUDE.md
View file

@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a multi-domain Laravel application called "pr-copilot" that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
This is a multi-domain Laravel application ("Pressekonto") that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
### Supported Domains
- **Main Portal**: pr-copilot.test - Main admin portal page
- **Main Portal**: pressekonto.test (local) / pressekonto.de (live) – Main admin portal page
- **Presseecho**: presseecho.test - Landing page with presseecho theme
- **Business Portal**: businessportal24.test - Landing page with business portal theme
@ -153,3 +153,232 @@ The project uses a **dual-port Vite setup** with separate configurations:
- `DOMAINS-CONFIG.md` - Detailed domain setup instructions
- `FORTIFY-SANCTUM-SETUP.md` - Authentication setup guide
- `VITE-SETUP.md` - Dual-port Vite architecture explanation
===
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- alpinejs (ALPINEJS) - v3
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.
- `volt-development` — Develops single-file Livewire components with Volt. Activates when creating Volt components, converting Livewire to Volt, working with @volt directive, functional or class-based Volt APIs; or when the user mentions Volt, single-file components, functional Livewire, or inline component logic in Blade files.
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit) or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 3 features. Do not use for editing factories, seeders, migrations, controllers, models, or non-test PHP code.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
## Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
## Searching Documentation (IMPORTANT)
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
### Search Syntax
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Artisan
- Run Artisan commands directly via the command line (e.g., `vendor/bin/sail artisan route:list`). Use `vendor/bin/sail artisan list` to discover available commands and `vendor/bin/sail artisan [command] --help` to check parameters.
- Inspect routes with `vendor/bin/sail artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `vendor/bin/sail artisan config:show app.name`, `vendor/bin/sail artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== sail rules ===
# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `vendor/bin/sail artisan list` and check their parameters with `vendor/bin/sail artisan [command] --help`.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `vendor/bin/sail artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
# Laravel 12
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
## Laravel 12 Structure
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
## Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== volt/core rules ===
# Livewire Volt
- Single-file Livewire components: PHP logic and Blade templates in one file.
- Always check existing Volt components to determine functional vs class-based style.
- IMPORTANT: Always use `search-docs` tool for version-specific Volt documentation and updated code examples.
- IMPORTANT: Activate `volt-development` every time you're working with a Volt or single-file component-related task.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
</laravel-boost-guidelines>

View file

@ -4,7 +4,7 @@
Diese Laravel-Anwendung unterstützt verschiedene Domains mit unterschiedlichen Styles:
- **Haupt-Website**: =https://pr-copilot.test - Haupt-Protal Admin-Page
- **Haupt-Website**: https://pressekonto.test (lokal) / https://pressekonto.de (live) – Haupt-Portal Admin-Page
- **APP_PRESSEECHO**: https://presseecho.test
- **APP_BUSINESSPORTAL**: https://businessportal24.test

View file

@ -8,18 +8,18 @@ Für dein Multi-Domain-Setup empfehle ich folgende Asset-URLs:
| Bereich | Domain | Asset-URL | Port | Verwendung |
|---------|--------|-----------|------|------------|
| **Backend** | pr-copilot.test | `assets.pr-copilot.test` | 5177 | Portal + FluxUI |
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.pr-copilot.test` | 5178 | Beide Frontend-Domains |
| **Backend** | pressekonto.test | `assets.pressekonto.test` | 5177 | Portal + FluxUI |
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.pressekonto.test` | 5178 | Beide Frontend-Domains |
### Warum diese URLs?
#### 1. **assets.pr-copilot.test** (Portal/Backend)
#### 1. **assets.pressekonto.test** (Portal/Backend)
- ✅ Kurz und prägnant
- ✅ Eindeutig dem Portal zugeordnet
- ✅ Keine zusätzliche Subdomain-Tiefe
- ✅ Folgt gängiger Konvention
#### 2. **assets-web.pr-copilot.test** (Web/Frontend)
#### 2. **assets-web.pressekonto.test** (Web/Frontend)
- ✅ Klar als "Web" (Frontend) gekennzeichnet
- ✅ Ein Asset-Server für beide Frontend-Domains
- ✅ Gute Trennung zu Portal-Assets
@ -31,23 +31,23 @@ Falls du andere URLs bevorzugst, hier sind Alternativen:
### Option A: Mit Suffix-Präfix
```
portal-assets.pr-copilot.test → Port 5177
web-assets.pr-copilot.test → Port 5178
portal-assets.pressekonto.test → Port 5177
web-assets.pressekonto.test → Port 5178
```
- ⚠️ Etwas länger
- ✅ Sehr explizit
### Option B: Mit "vite" im Namen
```
vite.pr-copilot.test → Port 5177
vite-web.pr-copilot.test → Port 5178
vite.pressekonto.test → Port 5177
vite-web.pressekonto.test → Port 5178
```
- ⚠️ Technologie-spezifisch (was wenn du später zu einem anderen Build-Tool wechselst?)
- ⚠️ Weniger klar was geladen wird
### Option C: Separate Domains pro Frontend
```
assets.pr-copilot.test → Port 5177 (Portal)
assets.pressekonto.test → Port 5177 (Portal)
assets.presseecho.test → Port 5178 (Presseecho)
assets.businessportal24.test → Port 5178 (Businessportal24)
```
@ -61,7 +61,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
```yaml
# Portal Assets (Backend)
- "traefik.http.routers.assets-portal.rule=Host(`assets.pr-copilot.test`)"
- "traefik.http.routers.assets-portal.rule=Host(`assets.pressekonto.test`)"
- "traefik.http.routers.assets-portal.entrypoints=websecure"
- "traefik.http.routers.assets-portal.tls=true"
- "traefik.http.routers.assets-portal.service=assets-portal-service"
@ -69,7 +69,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
- "traefik.http.services.assets-portal-service.loadbalancer.server.scheme=http"
# Web Assets (Frontend)
- "traefik.http.routers.assets-web.rule=Host(`assets-web.pr-copilot.test`)"
- "traefik.http.routers.assets-web.rule=Host(`assets-web.pressekonto.test`)"
- "traefik.http.routers.assets-web.entrypoints=websecure"
- "traefik.http.routers.assets-web.tls=true"
- "traefik.http.routers.assets-web.service=assets-web-service"
@ -89,8 +89,8 @@ ports:
```env
# Vite Asset Domains
ASSET_URL_PORTAL=https://assets.pr-copilot.test
ASSET_URL_WEB=https://assets-web.pr-copilot.test
ASSET_URL_PORTAL=https://assets.pressekonto.test
ASSET_URL_WEB=https://assets-web.pressekonto.test
# Vite Development Ports
VITE_PORT_PORTAL=5177
@ -102,11 +102,11 @@ VITE_PORT_WEB=5178
Füge folgende Einträge zu deiner `/etc/hosts` (Linux/Mac) oder `C:\Windows\System32\drivers\etc\hosts` (Windows) hinzu:
```
127.0.0.1 pr-copilot.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
127.0.0.1 assets.pr-copilot.test
127.0.0.1 assets-web.pr-copilot.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets-web.pressekonto.test
```
## Vite-Konfigurationen
@ -121,7 +121,7 @@ export default defineConfig({
host: "0.0.0.0",
port: 5177,
hmr: {
host: "assets.pr-copilot.test", // ← Asset-URL
host: "assets.pressekonto.test", // ← Asset-URL
protocol: "wss",
},
},
@ -139,7 +139,7 @@ export default defineConfig({
host: "0.0.0.0",
port: 5178,
hmr: {
host: "assets-web.pr-copilot.test", // ← Asset-URL
host: "assets-web.pressekonto.test", // ← Asset-URL
protocol: "wss",
},
},
@ -156,16 +156,16 @@ Browser-Request
↓
2. Laravel lädt View mit: @vite(['resources/css/web/theme-presseecho.css', ...])
↓
3. Vite-Helper generiert: <script src="https://assets-web.pr-copilot.test/@vite/client"></script>
<link href="https://assets-web.pr-copilot.test/resources/css/web/theme-presseecho.css">
3. Vite-Helper generiert: <script src="https://assets-web.pressekonto.test/@vite/client"></script>
<link href="https://assets-web.pressekonto.test/resources/css/web/theme-presseecho.css">
↓
4. Browser requested: assets-web.pr-copilot.test
4. Browser requested: assets-web.pressekonto.test
↓
5. Traefik routet zu: Container Port 5178
↓
6. Vite Web Server antwortet
↓
7. HMR WebSocket öffnet: wss://assets-web.pr-copilot.test
7. HMR WebSocket öffnet: wss://assets-web.pressekonto.test
↓
8. ✅ Hot Module Replacement funktioniert!
```
@ -175,8 +175,8 @@ Browser-Request
### 1. DNS-Auflösung testen
```bash
# Sollte zu 127.0.0.1 auflösen
ping assets.pr-copilot.test
ping assets-web.pr-copilot.test
ping assets.pressekonto.test
ping assets-web.pressekonto.test
```
### 2. Vite-Server starten
@ -195,15 +195,15 @@ Du solltest sehen:
### 3. Browser-Test
Öffne:
- https://pr-copilot.test (sollte Assets von assets.pr-copilot.test laden)
- https://presseecho.test (sollte Assets von assets-web.pr-copilot.test laden)
- https://businessportal24.test (sollte Assets von assets-web.pr-copilot.test laden)
- https://pressekonto.test (sollte Assets von assets.pressekonto.test laden)
- https://presseecho.test (sollte Assets von assets-web.pressekonto.test laden)
- https://businessportal24.test (sollte Assets von assets-web.pressekonto.test laden)
### 4. HMR-Test
1. Öffne Browser DevTools (F12)
2. Gehe zu "Network" Tab
3. Filter auf "WS" (WebSocket)
4. Du solltest Verbindungen zu `wss://assets.*.pr-copilot.test` sehen
4. Du solltest Verbindungen zu `wss://assets.*.pressekonto.test` sehen
5. Ändere eine CSS-Datei
6. Browser sollte automatisch neu laden (ohne vollständigen Page-Refresh)
@ -256,8 +256,8 @@ docker compose logs laravel.test | grep traefik
### ✅ Verwende diese Asset-URLs:
```
assets.pr-copilot.test → Port 5177 (Portal/Backend)
assets-web.pr-copilot.test → Port 5178 (Web/Frontend)
assets.pressekonto.test → Port 5177 (Portal/Backend)
assets-web.pressekonto.test → Port 5178 (Web/Frontend)
```
### ✅ Vorteile:

View file

@ -0,0 +1,232 @@
# Businessportal24 CSS Optimierung
## 📊 Übersicht
Die `theme-businessportal24.css` wurde optimiert und von **669 auf 560 Zeilen** reduziert.
---
## ❌ Entfernte Duplikate (verschoben nach shared-styles.css)
### 1. **Hero Banner Styles** (Zeilen 218-237)
```css
/* ENTFERNT - Jetzt in shared-styles.css */
.hero-gradient { ... }
.hero-title { ... }
.hero-subtitle { ... }
```
**Grund:** Identisch in beiden Themes, jetzt zentral verwaltet.
---
### 2. **Section Indicator** (Zeilen 262-271)
```css
/* ENTFERNT - Nutzt jetzt .gradient-indicator aus shared-styles.css */
.section-indicator { ... }
```
**Grund:** Nahezu identisch zu `.gradient-indicator` - gemeinsame Klasse nutzen.
---
### 3. **Industry Icon Badge** (duplikate Definition)
```css
/* ENTFERNT - Jetzt in shared-styles.css */
.industry-icon-badge img { ... }
```
**Grund:** Bereits in shared-styles.css definiert.
---
## ✅ Behaltene Businessportal24-spezifische Styles
### 1. **Float & Glow Design System**
```css
.variant-float-glow → Haupthintergrund
.variant-float-glow .card-hover:hover → Enhanced Shadow Glow
```
---
### 2. **Pagination mit Gradient**
```css
.pagination-btn → Glassy Pagination Buttons
.pagination-active → Gradient für aktive Seite
.pagination-nav → Navigation Arrows
```
---
### 3. **Glassmorphism Components**
```css
.glass-button → Glassmorphism Button Style
.company-glass-badge → Glassy Company Badge
```
---
### 4. **Content Cards mit Glow**
```css
.featured-content article → Float-Cards mit Glow
.press-releases-wrapper article → Press Release Cards
```
---
### 5. **Businessportal24 Utilities**
```css
.ad-badge → Anzeige-Kennzeichnung
.glow-soft → Weiche Glows
.glow-medium → Mittlere Glows
.glow-strong → Starke Glows
.shadow-elegant → Elegante Schatten
.shadow-card → Card Schatten
.shadow-premium → Premium Schatten
```
---
## 📦 Struktur der optimierten Datei
```
1. CSS Variables (Zeilen 1-190)
├── Theme-spezifische HSL-Farben
├── Dark Mode Variablen
├── Shadow-Definitionen
└── Zinc-Color-System
2. Base & Components (Zeilen 191-155)
├── Base Overrides
└── Component Overrides
3. Float & Glow Design (Zeilen 156-560)
├── Haupthintergrund
├── Filter Bar
├── AD Badge
├── Content Kacheln
├── Glass Button
├── Pagination
├── Glow Utilities
└── Responsive
```
---
## 🔄 Migration
### Schritt 1: Backup erstellen
```bash
cp resources/css/web/theme-businessportal24.css resources/css/web/theme-businessportal24-backup.css
```
### Schritt 2: Optimierte Version aktivieren
```bash
mv resources/css/web/theme-businessportal24-optimized.css resources/css/web/theme-businessportal24.css
```
### Schritt 3: Testen
- Businessportal24-Seite aufrufen
- Hero Banner prüfen
- Highlights-Slider prüfen
- Pagination prüfen
- Float & Glow Effekte prüfen
---
## 🎯 Vorteile
### 1. **Keine Duplikate mehr**
- Hero-Styles nur einmal (shared-styles.css)
- Industry-Icons zentral verwaltet
- Section-Indicator durch gradient-indicator ersetzt
### 2. **Bessere Wartbarkeit**
- Klare Trennung: Gemeinsam vs. Theme-spezifisch
- Einfachere Updates
- Konsistenz garantiert
### 3. **Kleinere Dateigröße**
- Von 669 auf 560 Zeilen (-16%)
- Weniger Code zum Laden
- Bessere Performance
### 4. **Theme-spezifische Identität bewahrt**
- Float & Glow Design bleibt
- Glassmorphism-Effekte bleiben
- Pagination-Gradient bleibt
- Alle Businessportal24-spezifischen Features intakt
---
## 📋 Checkliste nach Migration
- [ ] Hero Banner wird korrekt angezeigt
- [ ] Highlights-Slider funktioniert
- [ ] Gradient-Indicator erscheint bei Titles
- [ ] Pagination mit Gradient funktioniert
- [ ] Float & Glow Effekte auf Cards funktionieren
- [ ] Dark Mode funktioniert
- [ ] Glass-Button-Style funktioniert
- [ ] Industry-Icons werden angezeigt
- [ ] Responsive Design funktioniert
---
## 🔍 Vergleich: Vorher vs. Nachher
### Vorher (669 Zeilen)
```
✗ Hero-Styles dupliziert (20 Zeilen)
✗ Industry-Icons dupliziert (10 Zeilen)
✗ Section-Indicator redundant (10 Zeilen)
✗ Unstrukturiert
✗ Schwer wartbar
```
### Nachher (560 Zeilen)
```
✓ Keine Duplikate
✓ Zentral verwaltete gemeinsame Styles
✓ Klare Struktur
✓ Leicht wartbar
✓ Theme-Identität bewahrt
✓ -109 Zeilen (-16%)
```
---
## 💡 Best Practice für zukünftige Styles
### Fragen vor dem Hinzufügen neuer Styles:
1. **Ist dieser Style gemeinsam?**
- JA → In `shared-styles.css` einfügen
- NEIN → In Theme-CSS einfügen
2. **Gibt es einen ähnlichen Style bereits?**
- JA → Bestehenden Style erweitern
- NEIN → Neuen Style erstellen
3. **Ist dieser Style theme-spezifisch?**
- JA → Theme-CSS mit Kommentar
- NEIN → shared-styles.css
4. **Brauche ich CSS-Variables?**
- JA → In Theme-CSS im :root definieren
- NEIN → Direkte Werte nutzen
---
## 🚀 Zusammenfassung
Die Businessportal24-CSS ist jetzt:
- ✅ **Optimiert** (16% kleiner)
- ✅ **Duplikatfrei**
- ✅ **Gut strukturiert**
- ✅ **Wartbar**
- ✅ **Theme-Identität bewahrt**
Alle Businessportal24-spezifischen Features (Float & Glow, Glassmorphism, Pagination-Gradient) bleiben vollständig erhalten!

View file

@ -0,0 +1,353 @@
# CategoryService Dokumentation
Der `CategoryService` ist eine zentrale Service-Klasse für die Verwaltung aller Kategorien in der Anwendung.
## 📁 Pfad
```
app/Services/CategoryService.php
```
## 🎯 Zweck
Der CategoryService bietet eine zentrale Stelle für:
- Kategorie-Definitionen (Name, Slug, Icon, Farbe, etc.)
- Farb-Mappings und Gradients
- Icon-Pfade für Heroicons
- Helper-Methoden für Kategorie-Zugriff
## 📖 Verfügbare Methoden
### 1. `getCategories()`
Gibt alle verfügbaren Kategorien zurück.
```php
use App\Services\CategoryService;
$categories = CategoryService::getCategories();
// Returns: Array mit allen Kategorien
```
**Rückgabe-Struktur:**
```php
[
[
'name' => 'Wirtschaft',
'slug' => 'wirtschaft',
'description' => 'Unternehmensnachrichten, Finanzberichte, Wirtschaftstrends',
'count' => '2.450+',
'icon' => 'chart-bar',
'color' => 'blue',
],
// ... weitere Kategorien
]
```
### 2. `getCategoryBySlug(string $slug)`
Findet eine Kategorie anhand ihres Slugs.
```php
$category = CategoryService::getCategoryBySlug('wirtschaft');
// Returns: Array mit Kategorie-Daten oder null
```
### 3. `getCategorySlugs()`
Gibt alle Kategorie-Slugs als Array zurück.
```php
$slugs = CategoryService::getCategorySlugs();
// Returns: ['wirtschaft', 'technologie', 'gesundheit', ...]
```
### 4. `getColorGradients()`
Gibt alle Farb-Gradient-Mappings zurück.
```php
$gradients = CategoryService::getColorGradients();
// Returns: ['blue' => 'from-blue-500/10 to-blue-600/10', ...]
```
### 5. `getColorClasses()`
Gibt alle Farb-CSS-Klassen zurück.
```php
$classes = CategoryService::getColorClasses();
// Returns: ['blue' => 'text-blue-500', ...]
```
### 6. `getGradientForColor(string $color)`
Gibt den Gradient für eine bestimmte Farbe zurück.
```php
$gradient = CategoryService::getGradientForColor('blue');
// Returns: 'from-blue-500/10 to-blue-600/10'
```
### 7. `getClassForColor(string $color)`
Gibt die CSS-Klasse für eine bestimmte Farbe zurück.
```php
$class = CategoryService::getClassForColor('blue');
// Returns: 'text-blue-500'
```
### 8. `getIconPath(string $iconName)`
Generiert den Pfad zu einem Heroicon.
```php
$path = CategoryService::getIconPath('chart-bar');
// Returns: '/heroicons/optimized/24/outline/chart-bar.svg'
```
### 9. `count()`
Gibt die Gesamtanzahl der Kategorien zurück.
```php
$total = CategoryService::count();
// Returns: 12
```
## 🔧 Verwendungsbeispiele
### Beispiel 1: Kategorien in Blade anzeigen
```blade
@php
use App\Services\CategoryService;
$categories = CategoryService::getCategories();
@endphp
<div class="grid grid-cols-3 gap-4">
@foreach($categories as $category)
<a href="/kategorie/{{ $category['slug'] }}">
<div class="category-card">
<img src="{{ CategoryService::getIconPath($category['icon']) }}"
alt="{{ $category['name'] }}">
<h3>{{ $category['name'] }}</h3>
<p>{{ $category['description'] }}</p>
</div>
</a>
@endforeach
</div>
```
### Beispiel 2: Kategorie in Controller verwenden
```php
use App\Services\CategoryService;
class CategoryController extends Controller
{
public function show(string $slug)
{
$category = CategoryService::getCategoryBySlug($slug);
if (!$category) {
abort(404);
}
return view('kategorie', [
'category' => $category,
'allCategories' => CategoryService::getCategories(),
]);
}
}
```
### Beispiel 3: Kategorie-Navigation
```blade
@php
use App\Services\CategoryService;
$categories = CategoryService::getCategories();
@endphp
<nav class="category-nav">
@foreach($categories as $category)
<a href="/kategorie/{{ $category['slug'] }}"
class="nav-item {{ request()->segment(2) === $category['slug'] ? 'active' : '' }}">
{{ $category['name'] }}
</a>
@endforeach
</nav>
```
### Beispiel 4: Dropdown mit Kategorien
```blade
<select name="category" class="form-select">
<option value="">Kategorie wählen</option>
@foreach(App\Services\CategoryService::getCategories() as $cat)
<option value="{{ $cat['slug'] }}">{{ $cat['name'] }}</option>
@endforeach
</select>
```
### Beispiel 5: Kategorie-Filter in Livewire
```php
use App\Services\CategoryService;
use Livewire\Component;
class PressReleaseFilter extends Component
{
public $selectedCategory = '';
public $categories = [];
public function mount()
{
$this->categories = CategoryService::getCategories();
}
public function render()
{
return view('livewire.press-release-filter');
}
}
```
### Beispiel 6: Farb-Gradient verwenden
```blade
@php
use App\Services\CategoryService;
$category = CategoryService::getCategoryBySlug('wirtschaft');
$gradient = CategoryService::getGradientForColor($category['color']);
$iconColor = CategoryService::getClassForColor($category['color']);
@endphp
<div class="bg-gradient-to-br {{ $gradient }} rounded-lg p-4">
<img src="{{ CategoryService::getIconPath($category['icon']) }}"
class="h-8 w-8 {{ $iconColor }}"
alt="{{ $category['name'] }}">
<h3>{{ $category['name'] }}</h3>
</div>
```
## 🎨 Verfügbare Kategorien
Aktuell sind folgende Kategorien definiert:
| Name | Slug | Icon | Farbe |
|------|------|------|-------|
| Wirtschaft | wirtschaft | chart-bar | blue |
| Technologie | technologie | cpu-chip | purple |
| Gesundheit | gesundheit | heart | green |
| Finanzen | finanzen | currency-dollar | yellow |
| Automotive | automotive | truck | red |
| Immobilien | immobilien | building-office | indigo |
| Energie | energie | bolt | orange |
| Bildung | bildung | academic-cap | cyan |
| Handel | handel | shopping-cart | pink |
| Tourismus | tourismus | globe-alt | teal |
| Sport | sport | trophy | lime |
| Kultur | kultur | musical-note | violet |
## ➕ Neue Kategorie hinzufügen
Um eine neue Kategorie hinzuzufügen, bearbeiten Sie die `getCategories()` Methode in `app/Services/CategoryService.php`:
```php
public static function getCategories(): array
{
return [
// ... bestehende Kategorien
[
'name' => 'Neue Kategorie',
'slug' => 'neue-kategorie',
'description' => 'Beschreibung der neuen Kategorie',
'count' => '0+',
'icon' => 'icon-name', // Heroicon Name
'color' => 'emerald', // Farbe
],
];
}
```
**Wichtig:** Wenn Sie eine neue Farbe verwenden, fügen Sie diese auch in `getColorGradients()` und `getColorClasses()` hinzu!
## 🎯 Best Practices
1. **Verwenden Sie immer den CategoryService** statt hardcodierte Kategorien
2. **Caching:** Für bessere Performance können Kategorien gecacht werden
3. **Validierung:** Nutzen Sie `getCategoryBySlug()` zur Validierung von Kategorie-Parametern
4. **Konsistenz:** Alle Kategorie-Daten sollten nur über den Service abgerufen werden
## 🔄 Migration von inline Arrays
**Vorher:**
```blade
@php
$categories = [
['name' => 'Wirtschaft', 'slug' => 'wirtschaft', ...],
// ...
];
@endphp
```
**Nachher:**
```blade
@php
use App\Services\CategoryService;
$categories = CategoryService::getCategories();
@endphp
```
## 📊 Performance-Tipps
Wenn Sie die Kategorien häufig verwenden, können Sie sie in einem View Composer laden:
```php
// app/Providers/ViewServiceProvider.php
use App\Services\CategoryService;
use Illuminate\Support\Facades\View;
public function boot()
{
View::composer('*', function ($view) {
$view->with('globalCategories', CategoryService::getCategories());
});
}
```
Dann in Blade:
```blade
@foreach($globalCategories as $category)
<!-- ... -->
@endforeach
```
## 🧪 Testing
Beispiel für einen Test:
```php
use App\Services\CategoryService;
use Tests\TestCase;
class CategoryServiceTest extends TestCase
{
public function test_get_categories_returns_array()
{
$categories = CategoryService::getCategories();
$this->assertIsArray($categories);
$this->assertNotEmpty($categories);
}
public function test_get_category_by_slug()
{
$category = CategoryService::getCategoryBySlug('wirtschaft');
$this->assertNotNull($category);
$this->assertEquals('Wirtschaft', $category['name']);
}
}
```
## 📝 Changelog
- **v1.0** - Initiale Version mit allen 12 Kategorien
- Unterstützt Heroicons
- Farb-Mappings für alle Kategorien
- Helper-Methoden für einfachen Zugriff

View file

@ -0,0 +1,485 @@
# Komponenten-Dokumentation
Diese Dokumentation beschreibt alle wiederverwendbaren Blade-Komponenten für die Presseecho und Businessportal24 Themes.
## Inhaltsverzeichnis
1. [Layout-Komponenten](#layout-komponenten)
2. [Navigation-Komponenten](#navigation-komponenten)
3. [Content-Komponenten](#content-komponenten)
4. [Card-Komponenten](#card-komponenten)
5. [Verwendungsbeispiele](#verwendungsbeispiele)
---
## Layout-Komponenten
### Content-Layout
**Pfad:** `resources/views/components/web/content-layout.blade.php`
Hauptlayout für Unterseiten mit optionaler Sidebar.
**Props:**
- `$sidebar` (slot): Sidebar-Inhalt (optional)
- `$sidebarPosition` (string): Position der Sidebar - `'left'` oder `'right'` (default: `'right'`)
**Beispiel ohne Sidebar:**
```blade
<x-web.content-layout>
<article>
<!-- Ihr Content hier -->
</article>
</x-web.content-layout>
```
**Beispiel mit Sidebar:**
```blade
<x-web.content-layout>
<x-slot name="sidebar">
<x-web.sidebar-widget title="Filter">
<!-- Sidebar-Inhalte -->
</x-web.sidebar-widget>
</x-slot>
<article>
<!-- Hauptcontent -->
</article>
</x-web.content-layout>
```
### Page Header
**Pfad:** `resources/views/components/web/page-header.blade.php`
Header für Unterseiten mit Titel, Subtitle und optionalem Bild.
**Props:**
- `$title` (string, required): Hauptüberschrift
- `$subtitle` (string, optional): Untertitel
- `$meta` (slot, optional): Meta-Informationen (Badges, Datum, etc.)
- `$image` (string, optional): URL für Header-Bild
- `$compact` (bool): Kompakte Version (default: `false`)
**Beispiel:**
```blade
<x-web.page-header
title="Die Zukunft der KI"
subtitle="Exklusive Einblicke in die Herausforderungen">
<x-slot name="meta">
<span class="badge badge-primary">Exklusiv-Interview</span>
<span>17. Oktober 2024</span>
</x-slot>
</x-web.page-header>
```
---
## Navigation-Komponenten
### Main Navigation
**Pfad:** `resources/views/components/web/main-navigation.blade.php`
Sticky Navigation für verschiedene Themes.
**Props:**
- `$theme` (string): Theme-Name - `'presseecho'` oder `'businessportal24'` (default: `'presseecho'`)
- `$items` (array, optional): Custom Navigation-Items
**Beispiel Standard:**
```blade
<x-web.main-navigation theme="presseecho" />
```
**Beispiel Custom Items:**
```blade
<x-web.main-navigation
theme="presseecho"
:items="[
['label' => 'Kategorien', 'url' => '/kategorien'],
['label' => 'Autoren', 'url' => '/autoren'],
]"
/>
```
### Breadcrumb
**Pfad:** `resources/views/components/web/breadcrumb.blade.php`
Breadcrumb-Navigation für Unterseiten.
**Props:**
- `$items` (array, required): Array von Breadcrumb-Items
**Beispiel:**
```blade
<x-web.breadcrumb :items="[
['label' => 'Themendossiers', 'url' => '/themendossiers'],
['label' => 'KI & Innovation', 'url' => '/themendossiers/ki'],
['label' => 'Artikel-Titel']
]" />
```
---
## Content-Komponenten
### Section Header
**Pfad:** `resources/views/components/web/section-header.blade.php`
Header für Content-Sections.
**Props:**
- `$title` (string, required): Section-Titel
- `$subtitle` (string, optional): Subtitle
- `$size` (string): Größe - `'small'`, `'medium'`, `'large'` (default: `'medium'`)
**Beispiel:**
```blade
<x-web.section-header
title="Neueste Analysen"
subtitle="Fundierte Einschätzungen von Branchenexperten"
size="medium"
/>
```
### Hero Banner
**Pfad:** `resources/views/components/web/hero-banner.blade.php`
Hero-Section für Startseiten.
**Props:**
- `$theme` (string): Theme-Name (default: `'presseecho'`)
- `$title` (slot): Hero-Titel
- `$subtitle` (slot): Hero-Subtitle
**Beispiel:**
```blade
<x-web.hero-banner theme="presseecho">
<x-slot name="title">
Exklusive Fachmeldungen <span class="opacity-50">für Ihre Recherche</span>
</x-slot>
<x-slot name="subtitle">
Exklusive Wirtschaftsinformationen für Journalisten
</x-slot>
</x-web.hero-banner>
```
### Sidebar Widget
**Pfad:** `resources/views/components/web/sidebar-widget.blade.php`
Widget-Container für Sidebar-Elemente.
**Props:**
- `$title` (string, optional): Widget-Titel
- `$icon` (slot, optional): Icon als SVG
**Beispiel:**
```blade
<x-web.sidebar-widget title="Über den Autor">
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-secondary)]">
<!-- Avatar -->
</div>
<div>
<h4 class="font-semibold">Dr. Maria Schmidt</h4>
<p class="text-sm">Expertin für KI</p>
</div>
</div>
</x-web.sidebar-widget>
```
---
## Card-Komponenten
### Article Card
**Pfad:** `resources/views/components/web/article-card.blade.php`
Standard-Karte für Artikel.
**Props:**
- `$article` (array, required): Artikel-Daten
**Erforderliche Array-Struktur:**
```php
$article = [
'badge' => 'Analyse',
'badgeType' => 'primary', // primary, secondary, success, warning
'category' => 'Industrie 4.0',
'categoryBadgeType' => 'secondary',
'date' => '2024-10-16',
'dateFormatted' => '16. Okt 2024',
'title' => 'Artikeltitel',
'teaser' => 'Kurzbeschreibung...',
'author' => 'Thomas Müller',
'authorInitials' => 'TM',
'image' => 'https://...',
];
```
**Beispiel:**
```blade
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($articles as $article)
<x-web.article-card :article="$article" />
@endforeach
</div>
```
### Dossier Card
**Pfad:** `resources/views/components/web/dossier-card.blade.php`
Karte für Themendossiers.
**Props:**
- `$dossier` (array, required): Dossier-Daten
**Erforderliche Array-Struktur:**
```php
$dossier = [
'title' => 'KI in der Industrie',
'description' => 'Alle Facetten der künstlichen Intelligenz...',
'articleCount' => 12,
'image' => 'https://...',
];
```
**Beispiel:**
```blade
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($dossiers as $dossier)
<x-web.dossier-card :dossier="$dossier" />
@endforeach
</div>
```
### Highlight Card
**Pfad:** `resources/views/components/web/highlight-card.blade.php`
Karte für Highlights (wird im Slider verwendet).
**Props:**
- `$highlight` (array, required): Highlight-Daten
**Erforderliche Array-Struktur:**
```php
$highlight = [
'badge' => 'Exklusiv-Interview',
'badgeType' => 'primary',
'industry' => 'KI & Innovation',
'date' => '17. Oktober 2024',
'title' => 'Die Zukunft der KI',
'text' => 'Beschreibungstext...',
'author' => 'Dr. Maria Schmidt',
'authorRole' => 'Expertin für KI',
'initials' => 'MS',
'image' => 'https://...',
];
```
### Highlights Slider
**Pfad:** `resources/views/components/web/highlights-slider.blade.php`
Interaktiver Slider für Highlights mit Navigation und Keyboard-Support.
**Props:**
- `$highlights` (array, required): Array von Highlight-Daten
- `$theme` (string): Theme-Name (default: `'presseecho'`)
**Beispiel:**
```blade
@php
$highlights = [
[
'badge' => 'Exklusiv-Interview',
'badgeType' => 'primary',
'industry' => 'KI & Innovation',
'date' => '17. Oktober 2024',
'title' => 'Die Zukunft der KI',
'text' => 'Interview-Text...',
'author' => 'Dr. Maria Schmidt',
'authorRole' => 'Expertin',
'initials' => 'MS',
'image' => 'https://...',
],
// ... weitere Highlights
];
@endphp
<x-web.highlights-slider :highlights="$highlights" theme="presseecho" />
```
---
## Verwendungsbeispiele
### Beispiel 1: Artikel-Detailseite
```blade
@extends('web.layouts.web-master')
@section('content')
<main class="min-h-screen flex flex-col bg-white dark:bg-zinc-950">
<livewire:web.burger-menu />
<livewire:web.header />
<x-web.main-navigation theme="presseecho" />
<div class="container mx-auto px-4">
<x-web.breadcrumb :items="[
['label' => 'Themendossiers', 'url' => '/themendossiers'],
['label' => 'KI & Innovation', 'url' => '/ki'],
['label' => 'Artikel-Titel']
]" />
</div>
<x-web.page-header
title="Die Zukunft der KI"
subtitle="Exklusive Einblicke">
<x-slot name="meta">
<span class="badge badge-primary">Interview</span>
<span>17. Oktober 2024</span>
</x-slot>
</x-web.page-header>
<x-web.content-layout>
<x-slot name="sidebar">
<x-web.sidebar-widget title="Über den Autor">
<!-- Author Info -->
</x-web.sidebar-widget>
</x-slot>
<article class="prose prose-lg">
<!-- Artikel-Content -->
</article>
</x-web.content-layout>
<livewire:web.footer />
</main>
@endsection
```
### Beispiel 2: Kategorie-Übersicht
```blade
@extends('web.layouts.web-master')
@section('content')
<main class="min-h-screen flex flex-col bg-white dark:bg-zinc-950">
<livewire:web.burger-menu />
<livewire:web.header />
<x-web.main-navigation theme="presseecho" />
<div class="container mx-auto px-4">
<x-web.breadcrumb :items="[
['label' => 'Themendossiers', 'url' => '/themendossiers'],
['label' => 'KI & Innovation']
]" />
</div>
<x-web.page-header
title="KI & Innovation"
subtitle="Alle Artikel zum Thema"
image="https://..." />
<x-web.content-layout>
<x-slot name="sidebar">
<x-web.sidebar-widget title="Filter">
<!-- Filter-Optionen -->
</x-web.sidebar-widget>
</x-slot>
<div class="grid md:grid-cols-2 gap-6">
@foreach ($articles as $article)
<x-web.article-card :article="$article" />
@endforeach
</div>
</x-web.content-layout>
<livewire:web.footer />
</main>
@endsection
```
---
## CSS-Klassen
### Badge-Typen
- `badge` - Basis-Badge
- `badge-primary` - Primary-Farbe
- `badge-secondary` - Secondary-Farbe
- `badge-success` - Erfolg (Grün)
- `badge-warning` - Warnung (Gelb)
### Button-Typen
- `btn-primary` - Primary-Button mit Gradient
- `btn-secondary` - Secondary-Button
- `pagination-btn` - Pagination-Button
- `pagination-active` - Aktive Pagination-Seite
### Card-Typen
- `card` - Basis-Karte
- `card-hover` - Karte mit Hover-Effekt
- `highlight-card` - Highlight-Karte (Slider)
### Layout-Klassen
- `section-light-bg` - Heller Section-Hintergrund
- `section-gradient-bg` - Gradient Section-Hintergrund
- `page-header` - Page-Header-Container
- `page-header-compact` - Kompakter Page-Header
---
## Theme-Anpassung
Alle Komponenten respektieren die Theme-Variablen:
```css
:root {
--primary: /* HSL-Werte */
--secondary: /* HSL-Werte */
--color-primary: /* Hex-Werte */
--color-secondary: /* Hex-Werte */
}
```
**Presseecho:**
- Primary: `#345636` (Grün)
- Secondary: `#6b8f71` (Hellgrün)
**Businessportal24:**
- Primary: `#cf3628` (Rot)
- Secondary: `#f0834a` (Orange)
---
## Best Practices
1. **Komponenten-Verwendung:**
- Nutzen Sie immer die wiederverwendbaren Komponenten
- Vermeiden Sie duplizierte HTML-Strukturen
- Verwenden Sie Props für dynamische Inhalte
2. **CSS-Klassen:**
- Nutzen Sie die vordefinierten CSS-Klassen aus `shared-styles.css`
- Vermeiden Sie Inline-Styles, wo möglich
- Theme-spezifische Overrides in den jeweiligen Theme-CSS-Dateien
3. **Datenstruktur:**
- Halten Sie sich an die definierten Array-Strukturen
- Verwenden Sie `@php`-Blöcke für Daten-Definitionen
- Dokumentieren Sie Custom-Properties
4. **Accessibility:**
- Verwenden Sie semantische HTML-Tags
- Fügen Sie `aria-label` zu interaktiven Elementen hinzu
- Testen Sie die Keyboard-Navigation
---
## Support
Bei Fragen zur Verwendung der Komponenten:
- Siehe Beispiele unter `/resources/views/web/examples/`
- Konsultieren Sie diese Dokumentation
- Prüfen Sie die bestehenden Implementierungen in `presseecho.blade.php`

View file

@ -9,14 +9,14 @@ Füge die folgenden Variablen zu deiner `.env`-Datei hinzu:
```env
# Domain-Konfigurationen
APP_NAME=pr-copilot
APP_URL=https://pr-copilot.test
APP_NAME=pressekonto
APP_URL=https://pressekonto.test
APP_PRIMARY="#3ea3dc"
APP_ACCENT="#5c5c60"
# Entwicklungseinstellungen für Domains
DEV_SIMULATE_DOMAIN=false
DEV_SIMULATED_DOMAIN=pr-copilot.test
DEV_SIMULATED_DOMAIN=pressekonto.test
```
## Entwicklungsmodus
@ -34,10 +34,10 @@ unabhängig von der tatsächlichen URL.
Jede Domain kann eigene Einstellungen haben:
### Haupt-Website (pr-copilot.test)
### Haupt-Website (pressekonto.test)
- `APP_URL`: Die Domain für die Haupt-Website (https://pr-copilot.test)
- `APP_NAME`: Der Name der Haupt-Website (pr-copilot)
- `APP_URL`: Die Domain für die Haupt-Website (https://pressekonto.test)
- `APP_NAME`: Der Name der Haupt-Website (pressekonto)
- `APP_PRIMARY`: Die primäre Farbe im HEX-Format (#3ea3dc)
- `APP_ACCENT`: Die Akzentfarbe im HEX-Format (#5c5c60)
@ -62,7 +62,7 @@ Jede Domain kann eigene Einstellungen haben:
Um die verschiedenen Domains lokal zu testen, füge folgende Zeilen zu deiner Hosts-Datei hinzu:
```
127.0.0.1 pr-copilot.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
```
@ -101,7 +101,7 @@ Im Code kannst du auf die Domain-Konfiguration zugreifen:
Das Projekt verwendet bereits eine Multi-Domain-Architektur mit:
- **Hauptwebsite:** `pr-copilot.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
- **Hauptwebsite:** `pressekonto.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
- **Presseecho:** `presseecho.test` - Presseecho-Website mit rotem Theme (#e94a3c)
- **Business Portal:** `businessportal24.test` - Business Portal mit orangem Theme (#f69f0f)

View file

@ -2,7 +2,7 @@
## 🔍 Ursprüngliches Problem
Auf https://pr-copilot.test erschien der Fehler:
Auf https://pressekonto.test erschien der Fehler:
```
[Error] Not allowed to use restricted network host "0.0.0.0":
https://0.0.0.0:5178/@vite/client
@ -53,7 +53,7 @@ npm run dev:all
```bash
# Portal CSS wird korrekt geladen:
curl -Iks https://assets.pr-copilot.test/resources/css/portal.css
curl -Iks https://assets.pressekonto.test/resources/css/portal.css
# → HTTP/2 200 ✅
# Web Assets funktionieren:
@ -65,13 +65,13 @@ curl -Iks https://assets.businessportal24.test/resources/css/web/theme-businessp
| Domain | Asset-Domain | Port | Build-Dir | CSS-Datei |
|--------|-------------|------|-----------|-----------|
| pr-copilot.test | assets.pr-copilot.test | 5177 | build/portal | portal.css |
| pressekonto.test | assets.pressekonto.test | 5177 | build/portal | portal.css |
| presseecho.test | assets.presseecho.test | 5178 | build/web | theme-presseecho.css |
| businessportal24.test | assets.businessportal24.test | 5178 | build/web | theme-businessportal24.css |
## 🚀 Nächste Schritte
1. **Browser testen**: Öffne https://pr-copilot.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
1. **Browser testen**: Öffne https://pressekonto.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
2. **Keine Fehler mehr**: Die "0.0.0.0" Fehler sollten verschwunden sein
3. **Assets laden über HTTPS**: Alle CSS/JS-Dateien werden über die korrekten Asset-Subdomains geladen
@ -87,7 +87,7 @@ Falls du die Docker Container neu gestartet hast, stelle sicher dass:
1. ✅ DNS-Einträge in `/etc/hosts` vorhanden sind:
```
127.0.0.1 assets.pr-copilot.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```

View file

@ -4,7 +4,7 @@
### 1. `config/domains.php`
Jede Domain hat jetzt eine dedizierte `asset_url`:
- `portal`: `https://assets.pr-copilot.test`
- `portal`: `https://assets.pressekonto.test`
- `presseecho`: `https://assets.presseecho.test`
- `businessportal24`: `https://assets.businessportal24.test`
@ -39,7 +39,7 @@ sleep 5 && tail -30 /tmp/vite-server.log
### 2. Im Browser testen
Öffne mit Hard-Refresh (`Ctrl+Shift+R`):
- ✅ https://pr-copilot.test
- ✅ https://pressekonto.test
- ✅ https://presseecho.test
- ✅ https://businessportal24.test
@ -52,7 +52,7 @@ https://0.0.0.0:5178/@vite/client
**NACHHER (✅)**:
```
https://assets.pr-copilot.test/@vite/client
https://assets.pressekonto.test/@vite/client
https://assets.presseecho.test/@vite/client
https://assets.businessportal24.test/@vite/client
```

View file

@ -0,0 +1,439 @@
# Finale Optimierungs-Zusammenfassung
## Übersicht
Diese Dokumentation fasst alle durchgeführten Optimierungen für Presseecho und Businessportal24 zusammen, einschließlich der neuen Unterseiten-Komponenten.
---
## 📊 Statistiken
### Dateien erstellt/optimiert
| Kategorie | Anzahl | Details |
|-----------|--------|---------|
| **Blade-Komponenten** | 11 | Layout, Navigation, Content, Cards |
| **CSS-Dateien** | 3 | shared-styles, theme-presseecho, theme-businessportal24 |
| **Beispiel-Seiten** | 2 | Article-Detail, Category-Overview |
| **Dokumentation** | 2 | Component-Docs, Optimization-Summary |
### Code-Reduktion
| Datei | Vorher | Nachher | Reduktion |
|-------|--------|---------|-----------|
| **presseecho.blade.php** | 243 Zeilen | 211 Zeilen | -32 Zeilen |
| **theme-presseecho.css** | 166 Zeilen | 145 Zeilen | -21 Zeilen |
| **theme-businessportal24.css** | 512 Zeilen | 512 Zeilen | Optimiert |
### Wiederverwendbarkeit
- **11 Blade-Komponenten** können in allen Unterseiten verwendet werden
- **Gemeinsame CSS-Klassen** in `shared-styles.css` (726 Zeilen)
- **Theme-agnostische Komponenten** für beide Portale nutzbar
---
## 🎯 Durchgeführte Optimierungen
### 1. Layout-Komponenten
#### Content-Layout (`content-layout.blade.php`)
- Flexibles Layout mit optionaler Sidebar
- Unterstützt Left/Right Sidebar-Positionierung
- Responsive Grid-System
**Vorher:**
```blade
<div class="container mx-auto px-4">
<div class="grid lg:grid-cols-12 gap-8">
<main class="lg:col-span-8">...</main>
<aside class="lg:col-span-4">...</aside>
</div>
</div>
```
**Nachher:**
```blade
<x-web.content-layout>
<x-slot name="sidebar">...</x-slot>
<main>...</main>
</x-web.content-layout>
```
#### Page Header (`page-header.blade.php`)
- Unterstützt Titel, Subtitle, Meta-Infos
- Optionales Header-Bild
- Kompakte Variante verfügbar
### 2. Navigation-Komponenten
#### Main Navigation (`main-navigation.blade.php`)
- Theme-spezifische Navigation
- Custom Items möglich
- Sticky Positioning
- Gradient Underline Hover-Effekt
**Vorher:** 41 Zeilen inline HTML
**Nachher:** 1 Zeile Komponenten-Call
```blade
<x-web.main-navigation theme="presseecho" />
```
#### Breadcrumb (`breadcrumb.blade.php`)
- Automatische Home-Link
- Responsive Darstellung
- Accessibility-optimiert
### 3. Content-Komponenten
#### Section Header (`section-header.blade.php`)
- 3 Größen: small, medium, large
- Gradient Indicator
- Theme-agnostisch
#### Hero Banner (`hero-banner.blade.php`)
- Theme-spezifische Gradients
- Flexible Slots für Title/Subtitle
- Responsive Padding
#### Sidebar Widget (`sidebar-widget.blade.php`)
- Konsistentes Widget-Design
- Optionaler Titel + Icon
- Hover-Effekte
### 4. Card-Komponenten
Alle Card-Komponenten unterstützen:
- Dark Mode
- Hover-Effekte
- Responsive Images
- Industry Icons
- Badge-System
#### Article Card
- Standardisierte Artikel-Darstellung
- Author Avatars mit Initialen
- Kategorien und Badges
#### Dossier Card
- Spezielle Darstellung für Themendossiers
- Artikel-Counter
- Glassmorphism-Effekte
#### Highlight Card
- Für Highlights-Slider optimiert
- Grid-Layout mit Bild
- Meta-Informationen
### 5. Highlights Slider
**Features:**
- JavaScript-gesteuerter Slider
- Navigation Buttons
- Pagination Dots
- Keyboard-Navigation (Pfeiltasten)
- Touch/Scroll Support
- Smooth Scrolling
- Automatisches Update der aktiven Dots
**CSS-Optimierung:**
- `overflow-y: visible` für Hover-Effekte
- Padding-Adjustments für Schatten
- Scroll-Snap für besseres UX
---
## 🎨 CSS-Optimierungen
### Shared Styles (`shared-styles.css`)
**Neue Additions:**
- Page Header Styles (50 Zeilen)
- Sidebar Widget Styles (50 Zeilen)
- Navigation States (10 Zeilen)
- Responsive Breakpoints
**Vereinheitlichte Klassen:**
```css
/* Badges */
.badge, .badge-primary, .badge-secondary, etc.
/* Buttons */
.btn-primary, .btn-secondary, .pagination-btn
/* Cards */
.card, .card-hover, .highlight-card
/* Layout */
.section-light-bg, .section-gradient-bg, .page-header
```
### Theme-spezifische CSS
**Presseecho:**
- Softer Shadows
- Subtle Gradients
- Premium Borders
- Grün-Farbschema
**Businessportal24:**
- Float & Glow Design
- Glassmorphism Effects
- Shadow Glows
- Rot/Orange-Farbschema
---
## 📁 Dateistruktur
```
/var/www/html/
├── resources/
│ ├── css/
│ │ └── web/
│ │ ├── shared-styles.css ✨ (726 Zeilen)
│ │ ├── theme-presseecho.css ✅ (145 Zeilen)
│ │ └── theme-businessportal24.css ✅ (512 Zeilen)
│ │
│ └── views/
│ ├── components/
│ │ └── web/
│ │ ├── main-navigation.blade.php ✨
│ │ ├── breadcrumb.blade.php ✨
│ │ ├── page-header.blade.php ✨
│ │ ├── content-layout.blade.php ✨
│ │ ├── sidebar-widget.blade.php ✨
│ │ ├── section-header.blade.php ✅
│ │ ├── hero-banner.blade.php ✅
│ │ ├── article-card.blade.php ✅
│ │ ├── dossier-card.blade.php ✅
│ │ ├── highlight-card.blade.php ✅
│ │ └── highlights-slider.blade.php ✅
│ │
│ └── web/
│ ├── presseecho.blade.php ✅ (optimiert)
│ └── examples/
│ ├── article-detail.blade.php ✨
│ └── category-overview.blade.php ✨
│
└── COMPONENT_DOCUMENTATION.md ✨
└── FINAL_OPTIMIZATION_SUMMARY.md ✨
✨ = Neu erstellt
✅ = Optimiert/Aktualisiert
```
---
## 🚀 Verwendung für Unterseiten
### Artikel-Detailseite
```blade
<x-web.main-navigation theme="presseecho" />
<x-web.breadcrumb :items="[...]" />
<x-web.page-header title="..." subtitle="...">
<x-slot name="meta">...</x-slot>
</x-web.page-header>
<x-web.content-layout>
<x-slot name="sidebar">
<x-web.sidebar-widget title="Autor">...</x-web.sidebar-widget>
<x-web.sidebar-widget title="Tags">...</x-web.sidebar-widget>
</x-slot>
<article class="prose">...</article>
</x-web.content-layout>
```
### Kategorie-Übersicht
```blade
<x-web.main-navigation theme="presseecho" />
<x-web.breadcrumb :items="[...]" />
<x-web.page-header title="..." subtitle="..." image="..." />
<x-web.content-layout>
<x-slot name="sidebar">
<x-web.sidebar-widget title="Filter">...</x-web.sidebar-widget>
</x-slot>
<div class="grid md:grid-cols-2 gap-6">
@foreach ($articles as $article)
<x-web.article-card :article="$article" />
@endforeach
</div>
</x-web.content-layout>
```
### Themendossier
```blade
<x-web.page-header
title="KI & Innovation"
subtitle="Alle Facetten der KI"
image="..." />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($dossiers as $dossier)
<x-web.dossier-card :dossier="$dossier" />
@endforeach
</div>
```
---
## ✅ Vorteile der Optimierung
### 1. Code-Qualität
- **DRY-Prinzip**: Keine Code-Duplikation mehr
- **Wartbarkeit**: Änderungen nur an einer Stelle nötig
- **Lesbarkeit**: Klare Komponenten-Namen und Struktur
### 2. Konsistenz
- **Einheitliches Design**: Alle Seiten nutzen gleiche Komponenten
- **Theme-Support**: Beide Themes nutzen gleiche Basis
- **Responsive**: Alle Komponenten mobile-optimiert
### 3. Entwickler-Erfahrung
- **Schnellere Entwicklung**: Komponenten einfach zusammenstecken
- **Dokumentation**: Umfassende Docs verfügbar
- **Beispiele**: Referenz-Implementierungen vorhanden
### 4. Performance
- **Weniger CSS**: Gemeinsame Styles einmal geladen
- **Kleinere Dateien**: Reduzierte Blade-Templates
- **Caching**: Komponenten können gecacht werden
### 5. Skalierbarkeit
- **Neue Unterseiten**: Schnell erstellt mit Komponenten
- **Theme-Erweiterung**: Einfach neue Themes hinzufügen
- **Feature-Addition**: Zentrale Komponenten erweitern
---
## 📋 Checkliste für neue Unterseiten
- [ ] Layout-Komponente wählen (`content-layout`)
- [ ] Navigation hinzufügen (`main-navigation`)
- [ ] Breadcrumb implementieren (`breadcrumb`)
- [ ] Page-Header erstellen (`page-header`)
- [ ] Content mit passenden Cards füllen
- [ ] Sidebar-Widgets hinzufügen (optional)
- [ ] Theme-Variable setzen
- [ ] Responsive testen
- [ ] Dark Mode prüfen
- [ ] Accessibility validieren
---
## 🎓 Best Practices
### Komponenten-Verwendung
**✅ Gut:**
```blade
<x-web.article-card :article="$article" />
```
**❌ Schlecht:**
```blade
<article class="...">
<!-- 50 Zeilen duplizierter Code -->
</article>
```
### CSS-Klassen
**✅ Gut:**
```blade
<button class="btn-primary">Klick mich</button>
```
**❌ Schlecht:**
```blade
<button style="background: linear-gradient(...)">Klick mich</button>
```
### Datenstruktur
**✅ Gut:**
```php
@php
$articles = [
['title' => '...', 'author' => '...', ...]
];
@endphp
```
**❌ Schlecht:**
```blade
<!-- Hardcoded Daten direkt im HTML -->
```
---
## 🔄 Migration bestehender Seiten
### Schritt 1: Navigation ersetzen
```blade
<!-- Alt -->
<nav class="...">...</nav>
<!-- Neu -->
<x-web.main-navigation theme="presseecho" />
```
### Schritt 2: Content-Struktur anpassen
```blade
<!-- Alt -->
<div class="container">
<div class="grid">...</div>
</div>
<!-- Neu -->
<x-web.content-layout>
...
</x-web.content-layout>
```
### Schritt 3: Cards vereinheitlichen
```blade
<!-- Alt -->
<article class="...">...</article>
<!-- Neu -->
<x-web.article-card :article="$article" />
```
---
## 📚 Weitere Ressourcen
- **Komponenten-Dokumentation**: `/COMPONENT_DOCUMENTATION.md`
- **Beispiel-Seiten**: `/resources/views/web/examples/`
- **Theme-CSS**: `/resources/css/web/`
- **Shared-Styles**: `/resources/css/web/shared-styles.css`
---
## 🎉 Zusammenfassung
Mit dieser Optimierung haben wir:
1. ✅ **11 wiederverwendbare Komponenten** erstellt
2. ✅ **726 Zeilen gemeinsame CSS-Styles** zentralisiert
3. ✅ **Code-Duplikation eliminiert** (>100 Zeilen gespart)
4. ✅ **Konsistentes Design** über alle Seiten etabliert
5. ✅ **Unterseiten-Support** vollständig implementiert
6. ✅ **Theme-Flexibilität** für beide Portale gesichert
7. ✅ **Umfassende Dokumentation** bereitgestellt
8. ✅ **Best Practices** definiert und dokumentiert
Die Code-Basis ist jetzt **wartbar**, **skalierbar** und **zukunftssicher**! 🚀

View file

@ -54,7 +54,7 @@ composer require laravel/fortify laravel/sanctum
## Routen
### Web-Authentifizierung (pr-copilot.test)
### Web-Authentifizierung (pressekonto.test)
- `GET /login` - Anmeldeseite (Livewire)
- `POST /login` - Anmeldung (Livewire)
@ -68,7 +68,7 @@ composer require laravel/fortify laravel/sanctum
- `GET /verify-email` - E-Mail-Verifizierung (Livewire)
- `GET /confirm-password` - Passwort bestätigen (Livewire)
### API-Routen (api.pr-copilot.test)
### API-Routen (api.pressekonto.test)
- `GET /api/user` - Aktueller Benutzer (geschützt)
- `GET /api/profile` - Benutzerprofil (geschützt)
@ -78,7 +78,7 @@ composer require laravel/fortify laravel/sanctum
### Web-Authentifizierung
1. Besuchen Sie `http://portal.pr-copilot.test/login`
1. Besuchen Sie `http://portal.pressekonto.test/login`
2. Registrieren Sie sich oder melden Sie sich an
3. Nutzen Sie die verschiedenen Authentifizierungsfeatures
@ -87,7 +87,7 @@ composer require laravel/fortify laravel/sanctum
1. **Token erstellen**:
```bash
curl -X POST http://api.pr-copilot.test/login \
curl -X POST http://api.pressekonto.test/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password"}'
```
@ -95,7 +95,7 @@ curl -X POST http://api.pr-copilot.test/login \
2. **Geschützte Route aufrufen**:
```bash
curl -X GET http://api.pr-copilot.test/api/user \
curl -X GET http://api.pressekonto.test/api/user \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
@ -150,7 +150,7 @@ Bearbeiten Sie die Komponenten in `resources/views/livewire/auth/`:
```php
// Beispiel: Login-Komponente anpassen
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto')] class extends Component {
#[Validate('required|string|email')]
public string $email = '';

View file

@ -0,0 +1,431 @@
# Optimierungs-Zusammenfassung: Presseecho & Businessportal24
## 📋 Überblick
Die beiden Hauptseiten wurden vollständig refactored und optimiert. Alle gemeinsamen Komponenten wurden ausgelagert, CSS wurde vereinheitlicht und die Wartbarkeit deutlich verbessert.
---
## 🎯 Neue Komponenten
### 1. **highlights-slider.blade.php**
Wiederverwendbarer Slider für Highlight-Artikel mit:
- Navigation (Pfeil-Buttons)
- Pagination (Dots)
- Keyboard-Navigation
- Theme-Support (Presseecho/Businessportal24)
- Accessibility (ARIA-Labels)
**Verwendung:**
```blade
<x-web.highlights-slider :highlights="$highlights" theme="presseecho" />
```
---
### 2. **highlight-card.blade.php**
Einzelne Highlight-Card mit:
- Industry Icons (automatisch gemappt)
- Responsivem Design
- Hover-Effekten
- Author-Info
**Verwendung:**
```blade
<x-web.highlight-card :highlight="$highlight" />
```
---
### 3. **hero-banner.blade.php**
Wiederverwendbarer Hero-Banner mit:
- Theme-Support
- Decorative Pattern (optional für Businessportal24)
- Animationen
**Verwendung:**
```blade
<x-web.hero-banner theme="presseecho">
<x-slot name="title">
Ihr Titel <span class="text-[var(--color-white)]/50">mit Akzent</span>
</x-slot>
<x-slot name="subtitle">
Ihr Untertitel
</x-slot>
</x-web.hero-banner>
```
---
### 4. **section-header.blade.php**
Wiederverwendbarer Section-Header mit:
- 3 Größen (large, medium, small)
- Gradient-Indicator
- Optionaler Subtitle
**Verwendung:**
```blade
<x-web.section-header
title="Ihr Titel"
subtitle="Optionaler Untertitel"
size="medium" />
```
---
### 5. **article-card.blade.php**
Artikel-Card für "Neueste Analysen" mit:
- Bild
- Badge
- Author mit Initials
- Hover-Effekte
**Verwendung:**
```blade
<x-web.article-card :article="$article" />
```
---
### 6. **dossier-card.blade.php**
Dossier-Card für "Themendossiers" mit:
- Overlay-Design
- Gradient-Overlay
- Article-Count Badge
**Verwendung:**
```blade
<x-web.dossier-card :dossier="$dossier" />
```
---
## 🎨 CSS-Struktur
### **shared-styles.css**
Alle gemeinsamen Styles für beide Themes:
#### Neue Klassen:
```css
/* Slider */
.gradient-indicator → Gradient-Balken für Titel
.slider-wrapper → Slider-Container
.highlights-slider → Slider selbst
.slider-nav-btn → Navigation-Buttons
.slider-dots / .slider-dot → Pagination-Dots
/* Highlight Cards */
.highlight-card → Card-Container
.highlight-card-link → Link-Wrapper
.highlight-card-image → Bild-Bereich
.highlight-card-content → Content-Bereich
.highlight-meta → Meta-Informationen
.highlight-title → Titel
.highlight-text → Text/Teaser
.highlight-footer → Footer mit Company
.highlight-badge → Badge für Content-Type
/* Sections */
.section-light-bg → Heller Section-Hintergrund
.section-gradient-bg → Gradient Section-Hintergrund
/* Hero */
.hero-gradient → Hero-Banner Gradient
.hero-title → Hero-Titel mit Text-Shadow
.hero-subtitle → Hero-Untertitel
/* Icons */
.industry-icon-badge → Industry-Icon Styling
```
---
### **theme-presseecho.css**
Nur noch Presseecho-spezifische Overrides:
- Color-Variablen
- Dark-Mode-Variablen
- Softer Shadows
- Gradient-Borders
**Reduziert von 166 auf 141 Zeilen** ✅
---
### **theme-businessportal24.css**
Businessportal24-spezifische Styles:
- Color-Variablen
- Dark-Mode-Variablen
- Float & Glow Effects
- Pagination-Styles
- Glassmorphism Components
- Glow Utilities
**Optimiert von 669 auf 560 Zeilen (-16%)** ✅
Duplikate entfernt (Hero, Industry-Icons, Section-Indicator) ✅
---
## 📊 Optimierungs-Ergebnisse
### **Blade-Dateien**
#### Presseecho.blade.php
- **Vorher:** 602 Zeilen
- **Nachher:** ~280 Zeilen (optimiert)
- **Reduktion:** ~53% 🎉
#### Businessportal24.blade.php
- **Vorher:** 422 Zeilen
- **Nachher:** ~180 Zeilen (optimiert)
- **Reduktion:** ~57% 🎉
#### Code-Duplikation
- **Vorher:** Slider-Code 2x (480 Zeilen)
- **Nachher:** 1x Komponente (130 Zeilen)
- **Gesamt-Ersparnis:** 350 Zeilen ✅
---
### **CSS-Dateien**
#### theme-presseecho.css
- **Vorher:** 166 Zeilen
- **Nachher:** 141 Zeilen
- **Reduktion:** 15% 🎉
#### theme-businessportal24.css
- **Vorher:** 669 Zeilen
- **Nachher:** 560 Zeilen
- **Reduktion:** 16% 🎉
#### shared-styles.css
- **Vorher:** 280 Zeilen
- **Nachher:** 608 Zeilen
- **Hinzugefügt:** +328 Zeilen gemeinsame Styles
#### Gesamt CSS-Bilanz
- **Vorher:** 1.115 Zeilen (166 + 669 + 280)
- **Nachher:** 1.309 Zeilen (141 + 560 + 608)
- **Netto:** +194 Zeilen
- **Aber:** Keine Duplikate mehr, bessere Wartbarkeit! ✅
---
## ✅ Vorteile
### 1. **DRY (Don't Repeat Yourself)**
- Code nur einmal geschrieben
- Änderungen nur an einer Stelle
### 2. **Wartbarkeit**
- Klare Trennung von Concerns
- Komponenten sind unabhängig testbar
- Einfaches Debugging
### 3. **Konsistenz**
- Gleiches Design auf beiden Seiten
- Einheitliche Naming-Convention
- Zentrale Style-Verwaltung
### 4. **Performance**
- Weniger CSS-Duplikate
- Optimierte Selektoren
- Besseres Caching
### 5. **Wiederverwendbarkeit**
- Komponenten für neue Seiten nutzbar
- Theme-System einfach erweiterbar
- Schnelles Prototyping
### 6. **Accessibility**
- ARIA-Labels überall
- Keyboard-Navigation
- Focus-States
---
## 🚀 Verwendung der optimierten Dateien
### Blade-Dateien
#### Presseecho
```bash
# Backup erstellen
mv resources/views/web/presseecho.blade.php resources/views/web/presseecho-old.blade.php
# Optimierte Version aktivieren
mv resources/views/web/presseecho-optimized.blade.php resources/views/web/presseecho.blade.php
```
#### Businessportal24
```bash
# Backup erstellen
mv resources/views/web/businessportal24.blade.php resources/views/web/businessportal24-old.blade.php
# Optimierte Version aktivieren
mv resources/views/web/businessportal24-optimized.blade.php resources/views/web/businessportal24.blade.php
```
---
### CSS-Dateien
#### Businessportal24 CSS
```bash
# Backup erstellen
cp resources/css/web/theme-businessportal24.css resources/css/web/theme-businessportal24-backup.css
# Optimierte Version aktivieren
mv resources/css/web/theme-businessportal24-optimized.css resources/css/web/theme-businessportal24.css
# Oder direkt ersetzen (shared-styles.css ist bereits aktualisiert)
# Die Presseecho-CSS ist bereits optimiert
```
#### Wichtig
Die `shared-styles.css` wurde bereits optimiert und enthält jetzt alle gemeinsamen Styles. Beide Theme-CSS-Dateien importieren diese automatisch via `@import "./shared-styles.css";`
---
## 📝 Datenstruktur
### Highlights Array
```php
$highlights = [
[
'badge' => 'Exklusiv-Interview',
'badgeType' => 'primary', // oder 'secondary'
'industry' => 'KI & Innovation',
'date' => '17. Oktober 2024',
'title' => 'Ihr Titel',
'text' => 'Ihr Teaser-Text',
'author' => 'Dr. Maria Schmidt',
'authorRole' => 'Expertin', // optional
'initials' => 'MS', // optional
'image' => 'https://...',
],
];
```
### Articles Array
```php
$articles = [
[
'badge' => 'Analyse',
'badgeType' => 'primary',
'category' => 'Industrie 4.0',
'categoryBadgeType' => 'secondary',
'date' => '2024-10-16',
'dateFormatted' => '16. Okt 2024',
'title' => 'Ihr Titel',
'teaser' => 'Ihr Teaser',
'author' => 'Thomas Müller', // optional
'authorInitials' => 'TM', // optional
'image' => 'https://...',
],
];
```
### Dossiers Array
```php
$dossiers = [
[
'title' => 'KI in der Industrie',
'description' => 'Beschreibung',
'articleCount' => 12,
'image' => 'https://...',
],
];
```
---
## 🎯 Best Practices
1. **Komponenten klein halten** - Jede Komponente hat einen spezifischen Zweck
2. **Props dokumentieren** - `@props` klar definieren
3. **Theme-Support** - Theme-Parameter wo nötig
4. **Accessibility** - ARIA-Labels und Keyboard-Navigation
5. **Dark Mode** - Alle Komponenten Dark-Mode-ready
6. **Responsive** - Mobile-First Approach
---
## 📚 Weitere Optimierungsmöglichkeiten
1. **Lazy Loading** für Bilder implementieren
2. **Intersection Observer** für Scroll-Animationen
3. **Service Worker** für Offline-Support
4. **Image Optimization** mit WebP/AVIF
5. **Critical CSS** für above-the-fold Content
6. **Component Caching** mit Blade-Cache
---
## 🛠️ Wartung
### Bei neuen Sections:
1. Gemeinsame Styles in `shared-styles.css`
2. Theme-spezifische Overrides in Theme-CSS
3. Neue Komponente in `components/web/`
4. Datenstruktur dokumentieren
### Bei Style-Änderungen:
1. Prüfen ob gemeinsam oder theme-spezifisch
2. In entsprechender Datei ändern
3. Beide Themes testen
---
## ✨ Zusammenfassung
Die Optimierung hat die Codebase deutlich verbessert:
### Blade-Dateien
- **~50% weniger Code** in beiden Hauptdateien
- **6 neue wiederverwendbare Komponenten**
- **350 Zeilen** Duplikate eliminiert
- **Konsistentes Design** auf beiden Seiten
### CSS-Dateien
- **~16% kleiner** (theme-businessportal24.css)
- **~15% kleiner** (theme-presseecho.css)
- **Keine Duplikate** mehr zwischen Themes
- **Vereinheitlichte Struktur** mit shared-styles.css
- **328 Zeilen** gemeinsame Styles zentral verwaltet
### Vorteile
- ✅ **Bessere Wartbarkeit** - Änderungen nur an einer Stelle
- ✅ **DRY-Prinzip** - Don't Repeat Yourself konsequent umgesetzt
- ✅ **Theme-Identität** - Businessportal24 behält Float & Glow Design
- ✅ **Wiederverwendbar** - Komponenten für neue Seiten nutzbar
- ✅ **Performance** - Weniger Code, besseres Caching
- ✅ **Accessibility** - ARIA-Labels und Keyboard-Navigation
- ✅ **Responsive** - Mobile-First Approach
### Dateien
```
Neue Komponenten:
├── components/web/highlights-slider.blade.php (130 Zeilen)
├── components/web/highlight-card.blade.php (89 Zeilen)
├── components/web/hero-banner.blade.php (34 Zeilen)
├── components/web/section-header.blade.php (33 Zeilen)
├── components/web/article-card.blade.php (40 Zeilen)
└── components/web/dossier-card.blade.php (24 Zeilen)
Optimierte Blade-Dateien:
├── presseecho-optimized.blade.php (242 Zeilen)
└── businessportal24-optimized.blade.php (139 Zeilen)
Optimierte CSS-Dateien:
├── shared-styles.css (608 Zeilen) ✅ Aktualisiert
├── theme-presseecho.css (141 Zeilen) ✅ Optimiert
└── theme-businessportal24-optimized.css (560 Zeilen) → Zu aktivieren
Dokumentation:
├── OPTIMIZATION_SUMMARY.md (Hauptdokumentation)
└── BUSINESSPORTAL24_CSS_OPTIMIZATION.md (CSS-Details)
```
Alle Änderungen sind rückwärtskompatibel und können schrittweise implementiert werden.

View file

@ -6,7 +6,7 @@ Das Projekt nutzt ein dynamisches Theme-System mit 3 Domains:
| Domain | Theme | Primary Color | Secondary Color | CSS-Datei |
|--------|-------|---------------|-----------------|-----------|
| **pr-copilot.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
| **pressekonto.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
| **presseecho.test** | presseecho | #345636 (Grün) | #6b8f71 | `resources/css/web/theme-presseecho.css` |
| **businessportal24.test** | businessportal24 | #cf3628 (Rot) | #f0834a | `resources/css/web/theme-businessportal24.css` |
@ -276,8 +276,8 @@ npm run build:web
Der `ThemeServiceProvider` unterstützt einen `?theme=` URL-Parameter zum Testen:
```
https://pr-copilot.test?theme=presseecho
https://pr-copilot.test?theme=businessportal24
https://pressekonto.test?theme=presseecho
https://pressekonto.test?theme=businessportal24
```
### Via Host
@ -285,7 +285,7 @@ https://pr-copilot.test?theme=businessportal24
Einfach die entsprechende Domain aufrufen:
```
https://pr-copilot.test → Portal Theme
https://pressekonto.test → Portal Theme
https://presseecho.test → Presseecho Theme
https://businessportal24.test → Businessportal24 Theme
```

View file

@ -6,7 +6,7 @@ Jede Domain hat jetzt ihre eigene dedizierte Asset-Subdomain:
| Domain | Asset-Subdomain | Port | Vite-Config |
|--------|----------------|------|-------------|
| `pr-copilot.test` | `assets.pr-copilot.test` | 5177 | `vite.portal.config.js` |
| `pressekonto.test` | `assets.pressekonto.test` | 5177 | `vite.portal.config.js` |
| `presseecho.test` | `assets.presseecho.test` | 5178 | `vite.web.config.js` |
| `businessportal24.test` | `assets.businessportal24.test` | 5178 | `vite.web.config.js` |
@ -20,7 +20,7 @@ Füge folgende Einträge zu deiner Hosts-Datei hinzu (lokal auf deinem Host-Syst
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
```
127.0.0.1 assets.pr-copilot.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```
@ -47,7 +47,7 @@ npm run dev:all
### 1. `docker-compose.yml`
Neue Traefik-Routen hinzugefügt:
- `assets.pr-copilot.test` → Port 5177 (Portal)
- `assets.pressekonto.test` → Port 5177 (Portal)
- `assets.presseecho.test` → Port 5178 (Presseecho)
- `assets.businessportal24.test` → Port 5178 (Businessportal24)
@ -66,7 +66,7 @@ Nach dem Neustart kannst du testen:
```bash
# Im DevContainer:
curl -Ik https://assets.pr-copilot.test/@vite/client
curl -Ik https://assets.pressekonto.test/@vite/client
curl -Ik https://assets.presseecho.test/@vite/client
curl -Ik https://assets.businessportal24.test/@vite/client
```

View file

@ -6,7 +6,7 @@ Dieses Projekt verwendet **2 separate Vite-Ports** für unterschiedliche Domain-
| Bereich | Port | Vite Config | Tailwind Config | Domains | FluxUI |
|---------|------|-------------|-----------------|---------|--------|
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `pr-copilot.test` | ✅ Ja |
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `pressekonto.test` | ✅ Ja |
| **Frontend (Web)** | 5178 | `vite.web.config.js` | `tailwind.web.config.js` | `presseecho.test`, `businessportal24.test` | ❌ Nein |
## Warum 2 Ports?
@ -35,15 +35,15 @@ Startet beide Vite-Server parallel mit `concurrently`
npm run dev:portal
```
- Port: 5177
- HMR-Host: assets.pr-copilot.test
- Domain: pr-copilot.test
- HMR-Host: assets.pressekonto.test
- Domain: pressekonto.test
### Option 3: Nur Frontend (Web)
```bash
npm run dev:web
```
- Port: 5178
- HMR-Host: assets-web.pr-copilot.test
- HMR-Host: assets-web.pressekonto.test
- Domains: presseecho.test, businessportal24.test
## Production Build
@ -86,7 +86,7 @@ public/
## Theme-System
### Backend (Portal)
- **Domain:** pr-copilot.test
- **Domain:** pressekonto.test
- **Theme:** `portal`
- **CSS:** `resources/css/portal.css`
- **Views:** `resources/views/portal/**`
@ -110,8 +110,8 @@ public/
Beide Vite-Server laufen intern auf HTTP (`https: false`), Traefik übernimmt SSL-Terminierung:
- **Portal HMR:** `wss://assets.pr-copilot.test` → Port 5177
- **Web HMR:** `wss://assets-web.pr-copilot.test` → Port 5178
- **Portal HMR:** `wss://assets.pressekonto.test` → Port 5177
- **Web HMR:** `wss://assets-web.pressekonto.test` → Port 5178
## Troubleshooting

View file

@ -37,7 +37,7 @@ sudo nano /etc/hosts
**Einträge:**
```
127.0.0.1 assets.pr-copilot.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```
@ -51,7 +51,7 @@ Zurück im DevContainer:
tail -20 /tmp/vite-server.log
# Teste die Asset-URLs:
curl -Ik https://assets.pr-copilot.test/@vite/client
curl -Ik https://assets.pressekonto.test/@vite/client
curl -Ik https://assets.presseecho.test/@vite/client
curl -Ik https://assets.businessportal24.test/@vite/client
@ -63,7 +63,7 @@ curl -Ik https://assets.businessportal24.test/@vite/client
Öffne:
- https://businessportal24.test
- https://presseecho.test
- https://pr-copilot.test
- https://pressekonto.test
Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains geladen werden!
@ -71,7 +71,7 @@ Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains g
| Hauptdomain | Asset-Domain | Port | Build-Dir |
|------------|-------------|------|-----------|
| pr-copilot.test | assets.pr-copilot.test | 5177 | public/build/portal |
| pressekonto.test | assets.pressekonto.test | 5177 | public/build/portal |
| presseecho.test | assets.presseecho.test | 5178 | public/build/web |
| businessportal24.test | assets.businessportal24.test | 5178 | public/build/web |

View file

@ -65,7 +65,7 @@ Du benötigst **mindestens 2 Vite-Ports**:
│ └──────────────────┘ └──────────────────┘ │
│ ↓ ↓ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ pr-copilot.test │ │ presseecho.test │ │
│ │ pressekonto.test │ │ presseecho.test │ │
│ │ │ │ businessp24.test│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
@ -80,14 +80,14 @@ Du benötigst **mindestens 2 Vite-Ports**:
- Port: **5177**
- Input: `resources/css/portal.css`
- Build: `public/build/portal`
- HMR: `assets.pr-copilot.test`
- HMR: `assets.pressekonto.test`
- FluxUI: ✅ Ja
#### ✅ `vite.web.config.js`
- Port: **5178** (geändert von 5177!)
- Input: `theme-presseecho.css`, `theme-businessportal24.css`
- Build: `public/build/web`
- HMR: `assets-web.pr-copilot.test`
- HMR: `assets-web.pressekonto.test`
- FluxUI: ❌ Nein
#### ❌ `vite.config.js` (deprecated)
@ -190,14 +190,14 @@ npm run build:web
| URL | Vite-Port | Theme | FluxUI |
|-----|-----------|-------|--------|
| https://pr-copilot.test | 5177 | portal | ✅ |
| https://pressekonto.test | 5177 | portal | ✅ |
| https://presseecho.test | 5178 | presseecho | ❌ |
| https://businessportal24.test | 5178 | businessportal24 | ❌ |
## HMR (Hot Module Replacement)
- **Portal:** `wss://assets.pr-copilot.test` → Port 5177
- **Web:** `wss://assets-web.pr-copilot.test` → Port 5178
- **Portal:** `wss://assets.pressekonto.test` → Port 5177
- **Web:** `wss://assets-web.pressekonto.test` → Port 5178
⚠️ **Wichtig:** Traefik muss beide HMR-Hosts routen!
@ -210,12 +210,12 @@ Stelle sicher, dass Traefik beide Vite-Ports routet:
# docker-compose.yml oder traefik.yml
labels:
# Portal Assets
- "traefik.http.routers.vite-portal.rule=Host(`assets.pr-copilot.test`)"
- "traefik.http.routers.vite-portal.rule=Host(`assets.pressekonto.test`)"
- "traefik.http.routers.vite-portal.service=vite-portal"
- "traefik.http.services.vite-portal.loadbalancer.server.port=5177"
# Web Assets
- "traefik.http.routers.vite-web.rule=Host(`assets-web.pr-copilot.test`)"
- "traefik.http.routers.vite-web.rule=Host(`assets-web.pressekonto.test`)"
- "traefik.http.routers.vite-web.service=vite-web"
- "traefik.http.services.vite-web.loadbalancer.server.port=5178"
```
@ -223,11 +223,11 @@ labels:
### 2. DNS/Hosts-Datei aktualisieren
```
127.0.0.1 pr-copilot.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
127.0.0.1 assets.pr-copilot.test
127.0.0.1 assets-web.pr-copilot.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets-web.pressekonto.test
```
### 3. Blade-Templates aktualisieren
@ -258,7 +258,7 @@ Diese Dateien werden nicht mehr benötigt:
npm run dev:all
# 2. Browser öffnen
# - https://pr-copilot.test (Backend)
# - https://pressekonto.test (Backend)
# - https://presseecho.test (Frontend Grün)
# - https://businessportal24.test (Frontend Rot)

662
_docs/api/v1.yml Normal file
View file

@ -0,0 +1,662 @@
openapi: 3.1.0
info:
title: Pressekonto API
version: 1.0.0
description: >
REST API for customer integrations after the 2026 migration. Legacy
`api_key` query parameters and `X-Api-Key` headers are no longer accepted.
servers:
- url: /api/v1
security:
- sanctumBearer: []
tags:
- name: Press Releases
- name: Press Release Images
- name: Companies
- name: Categories
- name: Newsletter
paths:
/press-releases:
get:
tags: [Press Releases]
summary: List own press releases
description: Requires `press-releases:read`.
parameters:
- $ref: '#/components/parameters/StatusFilter'
- $ref: '#/components/parameters/PerPage'
responses:
'200':
description: Paginated press release collection.
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedPressReleases'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'410':
$ref: '#/components/responses/LegacyApiKeyGone'
post:
tags: [Press Releases]
summary: Create a press release
description: Requires `press-releases:write`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StorePressReleaseRequest'
responses:
'201':
description: Press release created.
content:
application/json:
schema:
$ref: '#/components/schemas/PressReleaseResponse'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
/press-releases/{pressRelease}:
get:
tags: [Press Releases]
summary: Show one own press release
description: Requires `press-releases:read`.
parameters:
- $ref: '#/components/parameters/PressReleaseId'
responses:
'200':
description: Press release resource.
content:
application/json:
schema:
$ref: '#/components/schemas/PressReleaseResponse'
'403':
$ref: '#/components/responses/Forbidden'
patch:
tags: [Press Releases]
summary: Update one own draft or rejected press release
description: Requires `press-releases:write`.
parameters:
- $ref: '#/components/parameters/PressReleaseId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdatePressReleaseRequest'
responses:
'200':
description: Press release updated.
content:
application/json:
schema:
$ref: '#/components/schemas/PressReleaseResponse'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
delete:
tags: [Press Releases]
summary: Delete one own unpublished press release
description: Requires `press-releases:write`.
parameters:
- $ref: '#/components/parameters/PressReleaseId'
responses:
'204':
description: Press release deleted.
'409':
$ref: '#/components/responses/Conflict'
/press-releases/{pressRelease}/images:
get:
tags: [Press Release Images]
summary: List images for one own press release
description: Requires `press-releases:read`.
parameters:
- $ref: '#/components/parameters/PressReleaseId'
responses:
'200':
description: Press release image collection.
content:
application/json:
schema:
$ref: '#/components/schemas/PressReleaseImageCollection'
'403':
$ref: '#/components/responses/Forbidden'
post:
tags: [Press Release Images]
summary: Upload an image for one own draft or rejected press release
description: Requires `press-release-images:write`. The uploaded image is limited to 5 MB.
parameters:
- $ref: '#/components/parameters/PressReleaseId'
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/StorePressReleaseImageRequest'
responses:
'201':
description: Press release image uploaded.
content:
application/json:
schema:
$ref: '#/components/schemas/PressReleaseImageResponse'
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
'422':
$ref: '#/components/responses/ValidationError'
/press-release-images/{pressReleaseImage}:
delete:
tags: [Press Release Images]
summary: Delete one own press release image
description: Requires `press-release-images:write`; only draft or rejected press releases may be changed.
parameters:
- $ref: '#/components/parameters/PressReleaseImageId'
responses:
'204':
description: Press release image deleted.
'403':
$ref: '#/components/responses/Forbidden'
'409':
$ref: '#/components/responses/Conflict'
/companies:
get:
tags: [Companies]
summary: List own companies
description: Requires `companies:read`.
parameters:
- $ref: '#/components/parameters/PerPage'
responses:
'200':
description: Paginated company collection.
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedCompanies'
'403':
$ref: '#/components/responses/Forbidden'
/companies/{company}:
get:
tags: [Companies]
summary: Show one own company
description: Requires `companies:read`.
parameters:
- $ref: '#/components/parameters/CompanyId'
responses:
'200':
description: Company resource.
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyResponse'
'403':
$ref: '#/components/responses/Forbidden'
/categories:
get:
tags: [Categories]
summary: List active categories
description: Requires `press-releases:read`.
responses:
'200':
description: Category collection.
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryCollection'
'403':
$ref: '#/components/responses/Forbidden'
/newsletter/subscribe:
post:
tags: [Newsletter]
summary: Subscribe an email address
description: Requires `newsletter:subscribe`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubscribeNewsletterRequest'
responses:
'201':
description: Subscription created.
content:
application/json:
schema:
$ref: '#/components/schemas/NewsletterSubscriptionResponse'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
components:
securitySchemes:
sanctumBearer:
type: http
scheme: bearer
bearerFormat: Sanctum personal access token
parameters:
PressReleaseId:
name: pressRelease
in: path
required: true
schema:
type: integer
PressReleaseImageId:
name: pressReleaseImage
in: path
required: true
schema:
type: integer
CompanyId:
name: company
in: path
required: true
schema:
type: integer
StatusFilter:
name: status
in: query
required: false
schema:
type: string
enum: [draft, review, published, rejected, archived]
PerPage:
name: per_page
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 25
responses:
Unauthenticated:
description: Missing or invalid Bearer token.
Forbidden:
description: Token ability missing or resource does not belong to the authenticated user.
Conflict:
description: Requested state transition is not allowed.
ValidationError:
description: Validation failed.
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
LegacyApiKeyGone:
description: Legacy API keys are no longer supported.
content:
application/json:
schema:
$ref: '#/components/schemas/LegacyApiKeyGone'
schemas:
StorePressReleaseRequest:
type: object
required: [company_id, category_id, language, title, text]
properties:
company_id:
type: integer
category_id:
type: integer
language:
type: string
minLength: 2
maxLength: 2
example: de
title:
type: string
maxLength: 255
text:
type: string
description: |
Body of the press release. Since Phase 7 (May 2026) this may
contain sanitized HTML produced by the rich-text editor
(allowed tags: p, br, h2, h3, strong, em, ul, ol, li,
blockquote, a). PMs imported before Phase 7 are plain text.
API consumers should treat the value as untrusted HTML.
backlink_url:
type: string
format: uri
nullable: true
keywords:
type: string
nullable: true
maxLength: 255
status:
type: string
enum: [draft, review]
default: draft
teaser_begin:
type: integer
nullable: true
minimum: 0
teaser_end:
type: integer
nullable: true
minimum: 0
no_export:
type: boolean
nullable: true
UpdatePressReleaseRequest:
allOf:
- $ref: '#/components/schemas/StorePressReleaseRequest'
required: []
SubscribeNewsletterRequest:
type: object
required: [portal, email]
properties:
portal:
type: string
enum: [presseecho, businessportal24]
email:
type: string
format: email
maxLength: 190
salutation_key:
type: string
nullable: true
maxLength: 20
first_name:
type: string
nullable: true
maxLength: 80
last_name:
type: string
nullable: true
maxLength: 80
StorePressReleaseImageRequest:
type: object
required: [image]
properties:
image:
type: string
format: binary
description: JPEG, PNG or WebP image, max. 5 MB.
title:
type: string
nullable: true
maxLength: 120
description:
type: string
nullable: true
maxLength: 500
copyright:
type: string
nullable: true
maxLength: 255
is_preview:
type: boolean
nullable: true
PressRelease:
type: object
properties:
id:
type: integer
uuid:
type: string
format: uuid
legacy:
$ref: '#/components/schemas/LegacyReference'
portal:
type: string
enum: [presseecho, businessportal24]
language:
type: string
title:
type: string
slug:
type: string
text:
type: string
backlink_url:
type: string
nullable: true
keywords:
type: string
nullable: true
status:
type: string
enum: [draft, review, published, rejected, archived]
hits:
type: integer
teaser:
type: object
properties:
begin:
type: integer
nullable: true
end:
type: integer
nullable: true
no_export:
type: boolean
published_at:
type: string
format: date-time
nullable: true
company:
$ref: '#/components/schemas/Company'
category:
$ref: '#/components/schemas/Category'
images:
type: array
items:
$ref: '#/components/schemas/PressReleaseImage'
created_at:
type: string
format: date-time
nullable: true
updated_at:
type: string
format: date-time
nullable: true
Company:
type: object
properties:
id:
type: integer
legacy:
$ref: '#/components/schemas/LegacyReference'
portal:
type: string
enum: [presseecho, businessportal24]
type:
type: string
nullable: true
name:
type: string
slug:
type: string
email:
type: string
nullable: true
website:
type: string
nullable: true
phone:
type: string
nullable: true
country_code:
type: string
nullable: true
is_active:
type: boolean
created_at:
type: string
format: date-time
nullable: true
updated_at:
type: string
format: date-time
nullable: true
Category:
type: object
properties:
id:
type: integer
legacy:
$ref: '#/components/schemas/LegacyReference'
portal:
type: string
enum: [presseecho, businessportal24]
nullable: true
is_active:
type: boolean
translations:
type: object
additionalProperties:
type: object
properties:
name:
type: string
slug:
type: string
description:
type: string
nullable: true
PressReleaseImage:
type: object
properties:
id:
type: integer
press_release_id:
type: integer
url:
type: string
format: uri
disk:
type: string
path:
type: string
title:
type: string
nullable: true
description:
type: string
nullable: true
copyright:
type: string
nullable: true
is_preview:
type: boolean
sort_order:
type: integer
width:
type: integer
nullable: true
height:
type: integer
nullable: true
mime:
type: string
nullable: true
created_at:
type: string
format: date-time
nullable: true
updated_at:
type: string
format: date-time
nullable: true
LegacyReference:
type: object
properties:
portal:
type: string
nullable: true
id:
type: integer
nullable: true
PressReleaseResponse:
type: object
properties:
data:
$ref: '#/components/schemas/PressRelease'
PressReleaseImageResponse:
type: object
properties:
data:
$ref: '#/components/schemas/PressReleaseImage'
CompanyResponse:
type: object
properties:
data:
$ref: '#/components/schemas/Company'
PaginatedPressReleases:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/PressRelease'
links:
type: object
meta:
type: object
PaginatedCompanies:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Company'
links:
type: object
meta:
type: object
CategoryCollection:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Category'
PressReleaseImageCollection:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/PressReleaseImage'
NewsletterSubscriptionResponse:
type: object
properties:
message:
type: string
data:
type: object
properties:
id:
type: integer
portal:
type: string
email:
type: string
format: email
is_confirmed:
type: boolean
LegacyApiKeyGone:
type: object
properties:
message:
type: string
example: Legacy API keys are no longer supported.
migration_url:
type: string
format: uri
docs_url:
type: string
format: uri
ValidationError:
type: object
properties:
message:
type: string
errors:
type: object
additionalProperties:
type: array
items:
type: string

View file

@ -0,0 +1,111 @@
<?php
namespace App\Actions\Admin;
use App\Listeners\RecordAuthActivity;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
class UserImpersonation
{
public const SessionKey = 'impersonate_from';
public function canInitiate(User $user): bool
{
return $user->is_super_admin || $user->can('users:manage');
}
public function canStart(User $admin, User $target): bool
{
return $this->canInitiate($admin)
&& ! $admin->is($target)
&& ! $this->isActive()
&& $this->canBeImpersonated($target)
&& $this->mayImpersonateRank($admin, $target);
}
public function canBeImpersonated(User $target): bool
{
return $target->canAccessCustomer();
}
/**
* Rang-Schutz: Ein Super-Admin darf nur von einem Super-Admin im Panel
* angemeldet werden. Verhindert, dass ein Admin (users:manage) das mächtigere
* Super-Admin-Konto übernimmt und in dessen Namen z. B. API-Token erzeugt.
*/
public function mayImpersonateRank(User $admin, User $target): bool
{
return $admin->is_super_admin || ! $target->is_super_admin;
}
/**
* @throws AuthorizationException
*/
public function start(User $admin, User $target): void
{
if (! $this->canInitiate($admin)) {
throw new AuthorizationException(__('Du darfst keine Benutzer-Impersonation starten.'));
}
if ($admin->is($target)) {
return;
}
if ($this->isActive()) {
throw new AuthorizationException(__('Verschachtelte Benutzer-Impersonation ist nicht erlaubt.'));
}
if (! $this->canBeImpersonated($target)) {
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
}
if (! $this->mayImpersonateRank($admin, $target)) {
throw new AuthorizationException(__('Super-Admins können nur von Super-Admins im Panel angemeldet werden.'));
}
session([self::SessionKey => $admin->getKey()]);
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($target);
}
public function stop(): ?User
{
$adminUserId = session(self::SessionKey);
session()->forget(self::SessionKey);
if (! is_numeric($adminUserId)) {
return null;
}
$admin = User::query()->find((int) $adminUserId);
if (! $admin?->canAccessAdmin()) {
return null;
}
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'impersonation');
Auth::login($admin);
return $admin;
}
public function isActive(): bool
{
return session()->has(self::SessionKey);
}
public function impersonator(): ?User
{
$adminUserId = session(self::SessionKey);
if (! is_numeric($adminUserId)) {
return null;
}
return User::query()->find((int) $adminUserId);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
@ -9,7 +10,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View file

@ -0,0 +1,141 @@
<?php
namespace App\Authorization;
use App\Services\Admin\AdminSystemHealth;
/**
* Zentraler Katalog der Spatie-Permissions. Einzige Quelle für die
* Permission-Namen und die Rollen-Zuordnung — genutzt vom
* RolesAndPermissionsSeeder und von der Betriebsüberwachung
* ({@see AdminSystemHealth}). Blade-/Route-Gates verwenden
* weiterhin die String-Literale (Laravel-idiomatisch), deren kanonische
* Definition aber hier liegt.
*/
final class Permission
{
public const PressReleasesRead = 'press-releases:read';
public const PressReleasesWrite = 'press-releases:write';
public const PressReleasesPublish = 'press-releases:publish';
public const PressReleaseImagesWrite = 'press-release-images:write';
public const CompaniesRead = 'companies:read';
public const CompaniesWrite = 'companies:write';
public const CategoriesManage = 'categories:manage';
public const FooterCodesManage = 'footer-codes:manage';
public const BillingManage = 'billing:manage';
public const ComplianceManage = 'compliance:manage';
public const SettingsManage = 'settings:manage';
public const NewsletterSubscribe = 'newsletter:subscribe';
public const UsersManage = 'users:manage';
public const RolesManage = 'roles:manage';
public const ApiImmediatePublication = 'api:immediate-publication';
/**
* Alle existierenden Permissions.
*
* @return list<string>
*/
public static function all(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::NewsletterSubscribe,
self::UsersManage,
self::RolesManage,
self::ApiImmediatePublication,
];
}
/**
* Nur an `admin` vergebene Verwaltungsrechte (Befund H2). Basis für den
* Deployment-Check „Berechtigungen ausgerollt".
*
* @return list<string>
*/
public static function adminOnly(): array
{
return [
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::UsersManage,
self::RolesManage,
];
}
/**
* Permissions der Rolle `editor` (Content-Rolle ohne destruktive/Verwaltung).
*
* @return list<string>
*/
public static function editor(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `customer`.
*
* @return list<string>
*/
public static function customer(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `api-only`.
*
* @return list<string>
*/
public static function apiOnly(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiAccessLogAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class AnalyzeLegacyApiAccessLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:analyze-legacy-access-logs
{paths* : Access-log files or glob patterns}
{--top=20 : Number of top entries per section}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Analysiert Legacy-API-Zugriffe aus Webserver-Access-Logs.';
/**
* Execute the console command.
*/
public function handle(LegacyApiAccessLogAnalyzer $analyzer): int
{
$paths = array_map('strval', (array) $this->argument('paths'));
$top = max(1, (int) $this->option('top'));
$report = $analyzer->analyze($paths, $top);
$this->info('Legacy-API-Access-Log-Auswertung');
$this->newLine();
$this->line("Dateien: {$report['summary']['files']}");
$this->line("Log-Zeilen: {$report['summary']['total_lines']}");
$this->line("Legacy-API-Requests: {$report['summary']['matched_requests']}");
$this->line("Requests mit api_key: {$report['summary']['legacy_key_requests']}");
$this->line("Eindeutige Client-IPs: {$report['summary']['unique_client_ips']}");
$this->line("Eindeutige API-Key-Fingerprints: {$report['summary']['unique_api_key_fingerprints']}");
if ($report['missing_paths'] !== []) {
$this->newLine();
$this->warn('Nicht lesbare Pfade:');
foreach ($report['missing_paths'] as $path) {
$this->line(" - {$path}");
}
}
$this->renderTopTable('Top Endpoints', $report['endpoints']);
$this->renderTopTable('Top Client-IPs', $report['client_ips']);
$this->renderTopTable('Statuscodes', $report['status_codes']);
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-api-access-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
/**
* @param array<string, int> $rows
*/
private function renderTopTable(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Wert', 'Requests'],
collect($rows)
->map(fn (int $count, string $value): array => [$value, $count])
->values()
->all()
);
}
}

View file

@ -0,0 +1,314 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\LegacyImportMap;
use App\Models\LegacyInvoice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
/**
* Archiviert alle Rechnungen aus den Legacy-Portalen in legacy_invoices (P6.5).
*
* Die Tabelle ist read-only und dient als Audit-Archiv.
* Rechnungen werden nicht in den neuen Rechnungskreis (invoices) übernommen.
*
* Verwendung:
* php artisan legacy:archive-invoices --dry-run
* php artisan legacy:archive-invoices --portal=presseecho
* php artisan legacy:archive-invoices
*/
class ArchiveLegacyInvoices extends Command
{
protected $signature = 'legacy:archive-invoices
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur zählen, nichts schreiben}
{--force : Bereits archivierte Rechnungen aktualisieren}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Archiviert Legacy-Rechnungen in legacy_invoices (read-only Archiv).';
private const PORTAL_CONFIG = [
'presseecho' => [
'connection' => 'mysql_presseecho',
'portal_enum' => Portal::Presseecho,
],
'businessportal24' => [
'connection' => 'mysql_businessportal',
'portal_enum' => Portal::Businessportal24,
],
];
private const CHUNK_SIZE = 200;
public function handle(): int
{
$portalOpt = $this->option('portal');
$isDryRun = (bool) $this->option('dry-run');
$isForce = (bool) $this->option('force');
$portals = $portalOpt === 'all'
? array_keys(self::PORTAL_CONFIG)
: [$portalOpt];
if (array_diff($portals, array_keys(self::PORTAL_CONFIG)) !== []) {
$this->error("Unbekanntes Portal: {$portalOpt}");
return self::FAILURE;
}
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalImported = 0;
$totalSkipped = 0;
$totalErrors = 0;
$report = [
'generated_at' => now()->toIso8601String(),
'portal' => $portalOpt,
'dry_run' => $isDryRun,
'force' => $isForce,
'portals' => [],
];
$start = microtime(true);
foreach ($portals as $portal) {
$config = self::PORTAL_CONFIG[$portal];
$portalReport = $this->archivePortal(
$portal,
$config['connection'],
$isDryRun,
$isForce,
);
$report['portals'][$portal] = $portalReport;
$imported = $portalReport['imported'];
$skipped = $portalReport['skipped'];
$errors = $portalReport['errors'];
$this->line(" [{$portal}] Archiviert: {$imported} | Übersprungen: {$skipped} | Fehler: {$errors}");
$this->line(" [{$portal}] Quelle: {$portalReport['source_count']} | ohne User-Mapping: {$portalReport['unmapped_user_count']} | PDF-Payload: {$portalReport['pdf_payload_count']}");
$totalImported += $imported;
$totalSkipped += $skipped;
$totalErrors += $errors;
}
$elapsed = round(microtime(true) - $start, 1);
$this->newLine();
$this->info("Gesamt: {$totalImported} archiviert | {$totalSkipped} übersprungen | {$totalErrors} Fehler | {$elapsed}s");
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-invoices-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
}
private function safeDateOrNull(mixed $value): ?string
{
if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return null;
}
return (string) $value;
}
private function archivePortal(
string $portal,
string $connection,
bool $isDryRun,
bool $isForce,
): array {
$imported = 0;
$skipped = 0;
$errors = 0;
$unmappedUserCount = 0;
$pdfPayloadCount = 0;
$amountCents = 0;
$statusCounts = [];
$hasBillingAddressTable = Schema::connection($connection)->hasTable('invoice_billing_address');
$hasUserPaymentTable = Schema::connection($connection)->hasTable('user_payment');
$hasUserPaymentOptionTable = Schema::connection($connection)->hasTable('user_payment_option');
$hasPaymentOptionTable = Schema::connection($connection)->hasTable('payment_option');
$hasPaymentOptionTranslationTable = Schema::connection($connection)->hasTable('payment_option_translation');
$sourceCount = DB::connection($connection)->table('invoice')->count();
DB::connection($connection)
->table('invoice')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($rows) use (
$portal, $connection, $isDryRun, $isForce, $hasBillingAddressTable, $hasUserPaymentTable,
$hasUserPaymentOptionTable, $hasPaymentOptionTable, $hasPaymentOptionTranslationTable,
&$imported, &$skipped, &$errors, &$unmappedUserCount, &$pdfPayloadCount, &$amountCents, &$statusCounts,
): void {
foreach ($rows as $row) {
try {
$status = (string) ($row->status ?? 'unknown');
$rowAmountCents = (int) round((float) $row->amount * 100);
$amountCents += $rowAmountCents;
$statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
$existingInvoice = LegacyInvoice::query()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first();
if ($existingInvoice && ! $isForce) {
$skipped++;
continue;
}
if ($isDryRun) {
$imported++;
continue;
}
$userId = null;
if ($row->user_id) {
$userMap = LegacyImportMap::query()
->where('legacy_portal', $portal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->user_id)
->first();
$userId = $userMap?->target_id;
}
if ($userId === null) {
$unmappedUserCount++;
}
$pdfPayload = $this->pdfPayload(
$connection,
$row,
$hasBillingAddressTable,
$hasUserPaymentTable,
$hasUserPaymentOptionTable,
$hasPaymentOptionTable,
$hasPaymentOptionTranslationTable,
);
$pdfPayloadCount++;
$taxCents = 0;
$totalCents = $rowAmountCents;
$paidAt = $this->safeDateOrNull($row->pay_date);
LegacyInvoice::query()->updateOrCreate(
[
'legacy_portal' => $portal,
'legacy_id' => $row->id,
],
[
'user_id' => $userId,
'legacy_user_id' => $row->user_id,
'number' => (string) $row->number,
'amount_cents' => $rowAmountCents,
'tax_cents' => $taxCents,
'total_cents' => $totalCents,
'status' => $status,
'invoice_date' => $this->safeDateOrNull($row->invoice_date),
'due_date' => $this->safeDateOrNull($row->due_date),
'paid_at' => $paidAt,
'payment_method' => $row->payment_method ?: null,
'pdf_path' => $existingInvoice?->pdf_path,
'raw_snapshot' => (array) $row,
'pdf_payload' => $pdfPayload,
'imported_at' => now(),
]
);
$imported++;
} catch (\Throwable $e) {
$errors++;
$this->warn(" ! Invoice {$row->id}: {$e->getMessage()}");
}
}
});
ksort($statusCounts);
return [
'source_count' => $sourceCount,
'imported' => $imported,
'skipped' => $skipped,
'errors' => $errors,
'unmapped_user_count' => $unmappedUserCount,
'pdf_payload_count' => $pdfPayloadCount,
'amount_cents' => $amountCents,
'status_counts' => $statusCounts,
];
}
/**
* @return array<string, mixed>
*/
private function pdfPayload(
string $connection,
object $invoice,
bool $hasBillingAddressTable,
bool $hasUserPaymentTable,
bool $hasUserPaymentOptionTable,
bool $hasPaymentOptionTable,
bool $hasPaymentOptionTranslationTable,
): array {
$userPayment = $hasUserPaymentTable && $invoice->user_payment_id
? $this->legacyRow($connection, 'user_payment', (int) $invoice->user_payment_id)
: null;
$userPaymentOption = $userPayment && $hasUserPaymentOptionTable
? $this->legacyRow($connection, 'user_payment_option', (int) $userPayment['user_payment_option_id'])
: null;
$paymentOption = $userPaymentOption && $hasPaymentOptionTable
? $this->legacyRow($connection, 'payment_option', (int) $userPaymentOption['payment_option_id'])
: null;
return [
'invoice' => (array) $invoice,
'billing_address' => $hasBillingAddressTable && $invoice->billing_address_id
? $this->legacyRow($connection, 'invoice_billing_address', (int) $invoice->billing_address_id)
: null,
'user_payment' => $userPayment,
'user_payment_option' => $userPaymentOption,
'payment_option' => $paymentOption,
'payment_option_translation' => $paymentOption && $hasPaymentOptionTranslationTable
? $this->paymentOptionTranslation($connection, (int) $paymentOption['id'])
: null,
];
}
/**
* @return array<string, mixed>|null
*/
private function paymentOptionTranslation(string $connection, int $paymentOptionId): ?array
{
$row = DB::connection($connection)
->table('payment_option_translation')
->where('id', $paymentOptionId)
->whereIn('lang', ['de', 'de_DE', 'de_AT', 'de_CH', 'en'])
->orderByRaw("CASE lang WHEN 'de' THEN 0 WHEN 'de_DE' THEN 1 WHEN 'de_AT' THEN 2 WHEN 'de_CH' THEN 3 ELSE 4 END")
->first();
return $row ? (array) $row : null;
}
/**
* @return array<string, mixed>|null
*/
private function legacyRow(string $connection, string $table, int $id): ?array
{
$row = DB::connection($connection)
->table($table)
->where('id', $id)
->first();
return $row ? (array) $row : null;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Console\Command;
/**
* Weist aktiven, komplett rollenlosen Nutzern eine Portal-Rolle zu.
*
* Hintergrund: Guest-/Auth-Redirects lenken einen eingeloggten Nutzer ohne
* admin/editor/customer-Rolle auf die Startseite (bootstrap/app.php →
* redirectUsersTo). Legacy-Importe oder ein fehlgeschlagener Rollen-Sync nach
* dem User-Insert können solche „hängenden" Konten erzeugen. Dieser Nachlauf
* heilt sie. api-only-Konten besitzen bereits eine Rolle und werden bewusst
* NICHT angefasst (kein Portal-Zugriff gewollt).
*/
class BackfillPortalRole extends Command
{
protected $signature = 'users:backfill-portal-role
{--role=customer : Zuzuweisende Rolle}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Weist aktiven, rollenlosen Nutzern eine Portal-Rolle (Standard: customer) zu.';
public function handle(UserRolePermissionSyncService $roleSync): int
{
$role = (string) $this->option('role');
$dryRun = (bool) $this->option('dry-run');
$ids = User::query()
->where('is_active', true)
->whereDoesntHave('roles')
->pluck('id');
if ($ids->isEmpty()) {
$this->info('Keine aktiven rollenlosen Nutzer gefunden – nichts zu tun.');
return self::SUCCESS;
}
if ($dryRun) {
$this->warn("[DRY-RUN] {$ids->count()} aktive rollenlose Nutzer würden die Rolle '{$role}' erhalten.");
return self::SUCCESS;
}
$assigned = 0;
foreach ($ids->chunk(500) as $chunk) {
User::query()->whereIn('id', $chunk)->get()->each(function (User $user) use ($roleSync, $role, &$assigned): void {
$roleSync->assignRoleAndSyncPermissions($user, $role);
$assigned++;
});
}
$this->info("{$assigned} aktiven rollenlosen Nutzern die Rolle '{$role}' zugewiesen.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,170 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Korrigiert created_at / updated_at auf bereits importierten Datensätzen.
*
* Verwendet Cross-DB-JOINs (gleicher MySQL-Server), um die Legacy-Timestamps
* in einer einzigen SQL-Operation auf die neuen Tabellen zu übertragen.
* Viel schneller als ein erneuter --force Import.
*
* Verwendung:
* php artisan legacy:fix-timestamps --dry-run # nur zählen
* php artisan legacy:fix-timestamps # alle Entitäten, beide Portale
* php artisan legacy:fix-timestamps --entity=companies --portal=presseecho
*/
class FixLegacyTimestamps extends Command
{
protected $signature = 'legacy:fix-timestamps
{--entity=all : Entität (users|companies|contacts|press-releases|all)}
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
/** Mapping: Portal → Legacy-Connection; der Schema-Name kommt aus deren Config (.env). */
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
private const ENTITY_CONFIG = [
'users' => [
'new_table' => 'users',
'legacy_table_name' => 'sf_guard_user',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'companies' => [
'new_table' => 'companies',
'legacy_table_name' => 'company',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'contacts' => [
'new_table' => 'contacts',
'legacy_table_name' => 'contact',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'press-releases' => [
'new_table' => 'press_releases',
'legacy_table_name' => 'press_release',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
// Veröffentlichte Meldungen: published_at = Legacy-created_at
// (Publikationsdatum, Sortier-/Cluster-Schlüssel im Frontend).
'derive_published_at' => true,
],
];
public function handle(): int
{
$entityOpt = $this->option('entity');
$portalOpt = $this->option('portal');
$isDryRun = (bool) $this->option('dry-run');
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_CONNECTIONS) : [$portalOpt];
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalUpdated = 0;
$start = microtime(true);
foreach ($entities as $entity) {
$config = self::ENTITY_CONFIG[$entity] ?? null;
if (! $config) {
$this->error("Unbekannte Entität: {$entity}");
continue;
}
foreach ($portals as $portal) {
$legacyDb = $this->legacySchema($portal);
if (! $legacyDb) {
$this->error("Unbekanntes Portal oder fehlende Legacy-DB-Config: {$portal}");
continue;
}
$updated = $this->fixEntity($config, $portal, $legacyDb, $isDryRun);
$totalUpdated += $updated;
$this->line(" [{$entity}] [{$portal}] → {$updated} Datensätze ".($isDryRun ? 'würden aktualisiert' : 'aktualisiert'));
}
}
$elapsed = round(microtime(true) - $start, 1);
$this->newLine();
$this->info("Gesamt: {$totalUpdated} Datensätze in {$elapsed}s.");
return self::SUCCESS;
}
private function legacySchema(string $portal): ?string
{
$connection = self::PORTAL_CONNECTIONS[$portal] ?? null;
if ($connection === null) {
return null;
}
$database = config("database.connections.{$connection}.database");
return is_string($database) && $database !== '' ? $database : null;
}
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
{
$newTable = $config['new_table'];
$legacyTable = $config['legacy_table_name'];
$legacyCreated = $config['legacy_created'];
$legacyUpdated = $config['legacy_updated'];
$derivePublishedAt = ! empty($config['derive_published_at']);
// Bei veröffentlichten Meldungen muss published_at dem Publikationsdatum
// (= Legacy-created_at) entsprechen; sonst sortiert/clustert das Frontend
// nach einem falschen Datum.
$publishedAtMismatch = $derivePublishedAt
? " OR (n.status = 'published' AND (n.published_at IS NULL OR n.published_at != lc.{$legacyCreated}))"
: '';
$publishedAtSet = $derivePublishedAt
? ", n.published_at = CASE WHEN n.status = 'published' THEN lc.{$legacyCreated} ELSE n.published_at END"
: '';
if ($isDryRun) {
// Nur zählen: wie viele Datensätze hätten falsche Timestamps?
return (int) DB::selectOne("
SELECT COUNT(*) as cnt
FROM {$newTable} n
JOIN legacy_import_map m
ON m.target_id = n.id
AND m.target_table = '{$newTable}'
AND m.legacy_portal = '{$portal}'
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
WHERE n.created_at != lc.{$legacyCreated}
OR n.updated_at != lc.{$legacyUpdated}{$publishedAtMismatch}
")->cnt ?? 0;
}
return DB::affectingStatement("
UPDATE {$newTable} n
JOIN legacy_import_map m
ON m.target_id = n.id
AND m.target_table = '{$newTable}'
AND m.legacy_portal = '{$portal}'
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
SET
n.created_at = lc.{$legacyCreated},
n.updated_at = lc.{$legacyUpdated}{$publishedAtSet}
");
}
}

View file

@ -30,7 +30,7 @@ class GenerateDomainFavicons extends Command
$faviconDir = public_path('img/favicons');
// Erstelle das Favicon-Verzeichnis, wenn es nicht existiert
if (!File::exists($faviconDir)) {
if (! File::exists($faviconDir)) {
File::makeDirectory($faviconDir, 0755, true);
$this->info("Verzeichnis {$faviconDir} erstellt.");
}
@ -53,8 +53,9 @@ class GenerateDomainFavicons extends Command
$faviconPath = "{$faviconDir}/{$theme}-favicon.ico";
// Wenn die Datei bereits existiert, frage, ob sie überschrieben werden soll
if (File::exists($faviconPath) && !$this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) {
if (File::exists($faviconPath) && ! $this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) {
$this->info("Favicon für '{$theme}' übersprungen.");
continue;
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Console\Command;
/**
* Fälligkeitsprüfung des manuellen Rechnungskreises (MAN-).
*
* Läuft täglich über den Scheduler und stellt für aktive Legacy-
* Zahlungsvereinbarungen (ohne Stripe-Subscription) die fälligen
* Rechnungen aus — wie im Legacy-System. Nicht abrechenbare
* Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt und
* beim nächsten Lauf erneut geprüft.
*/
class GenerateManualInvoices extends Command
{
protected $signature = 'billing:generate-manual-invoices
{--dry-run : Nur anzeigen, was fällig ist}
{--limit=50 : Maximale Anzahl Vereinbarungen pro Lauf}';
protected $description = 'Stellt fällige Rechnungen des manuellen Legacy-Rechnungskreises (MAN-) aus';
public function handle(ManualInvoiceService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$due = $service->duePaymentOptions(limit: $limit);
if ($due->isEmpty()) {
$this->info('Keine fälligen Zahlungsvereinbarungen gefunden.');
return self::SUCCESS;
}
$created = 0;
$skipped = 0;
foreach ($due as $option) {
if ($dryRun) {
$this->line(sprintf(
'[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)',
$option->id,
$option->user_id,
$option->current_period_end->toDateString(),
));
continue;
}
$invoice = $service->invoiceFor($option);
if ($invoice) {
$created++;
$this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id));
} else {
$skipped++;
$this->warn(sprintf('Vereinbarung #%d übersprungen (siehe Log).', $option->id));
}
}
if (! $dryRun) {
$this->info(sprintf('%d Rechnung(en) erstellt, %d übersprungen.', $created, $skipped));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,292 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserPaymentOptionStatus;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\UserPaymentOption;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
/**
* Leitet die noch aktiven, jährlich wiederkehrenden Legacy-Zahlungs-
* vereinbarungen aus dem Rechnungsarchiv ab und migriert sie als
* `grandfathered` in `user_payment_options` (D-13, Kriterien 12.06.2026).
*
* Quelle ist ausschließlich das read-only Archiv `legacy_invoices` (D-12):
* Pro (Portal, Legacy-Vereinbarung) zählt die jüngste Rechnung. Aktiv ist,
* was dort `payment_option.type = recurring`, `user_payment_option.status
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
*
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date` —
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
* Legacy-Rechnung aus.
*
* Wiederholbar (D-18): Re-Runs aktualisieren bestehende Einträge anhand
* der Legacy-IDs in `legacy_conditions`, statt Duplikate anzulegen —
* die Kern-Migration läuft kurz vor dem Relaunch erneut.
*
* Verwendung:
* php artisan legacy:grandfather-subscriptions --dry-run
* php artisan legacy:grandfather-subscriptions --as-of=2026-06-12
* php artisan legacy:grandfather-subscriptions
*/
class GrandfatherLegacySubscriptions extends Command
{
protected $signature = 'legacy:grandfather-subscriptions
{--dry-run : Nur anzeigen, nichts schreiben}
{--as-of= : Stichtag für die Aktiv-Prüfung (Default: heute)}
{--grace-months=12 : Wie lange eine überfällige next_due_date noch als aktiv gilt}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Migriert aktive wiederkehrende Legacy-Zahlungen aus dem Rechnungsarchiv nach user_payment_options (grandfathered).';
private const CHUNK_SIZE = 200;
public function handle(): int
{
$isDryRun = (bool) $this->option('dry-run');
$asOf = $this->option('as-of') ? Carbon::parse($this->option('as-of'))->startOfDay() : today();
$graceMonths = max(0, (int) $this->option('grace-months'));
$staleBefore = $asOf->copy()->subMonths($graceMonths);
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$candidates = $this->collectLatestRecurringAgreements();
$report = [
'generated_at' => now()->toIso8601String(),
'as_of' => $asOf->toDateString(),
'grace_months' => $graceMonths,
'dry_run' => $isDryRun,
'created' => [],
'updated' => [],
'stale_skipped' => [],
'immediately_due' => [],
];
foreach ($candidates as $candidate) {
$nextDue = $candidate['next_due_date'];
if ($nextDue->lessThan($staleBefore)) {
$report['stale_skipped'][] = $this->describe($candidate);
continue;
}
if ($nextDue->lessThanOrEqualTo($asOf)) {
$report['immediately_due'][] = $this->describe($candidate);
}
if ($isDryRun) {
$this->line(sprintf('[dry-run] %s', $this->describe($candidate)));
continue;
}
$paymentOption = $this->resolveCatalogOption($candidate);
$existing = $this->findExisting($candidate);
$attributes = [
'user_id' => $candidate['user_id'],
'payment_option_id' => $paymentOption->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => $candidate['valid_until_date']?->toDateString(),
'current_period_start' => $candidate['period_start']->toDateString(),
'current_period_end' => $nextDue->toDateString(),
'stripe_subscription_id' => null,
'legacy_conditions' => $candidate['legacy_conditions'],
];
if ($existing) {
$existing->update($attributes);
$report['updated'][] = $this->describe($candidate);
} else {
UserPaymentOption::query()->create($attributes);
$report['created'][] = $this->describe($candidate);
}
}
$this->table(
['Ergebnis', 'Anzahl'],
[
['Kandidaten (aktiv, recurring)', count($candidates)],
['Neu angelegt', count($report['created'])],
['Aktualisiert (Re-Run)', count($report['updated'])],
['Übersprungen (stale)', count($report['stale_skipped'])],
['Davon sofort fällig', count($report['immediately_due'])],
],
);
foreach ($report['immediately_due'] as $line) {
$this->warn('Sofort fällig (MAN-Lauf rechnet beim nächsten Lauf ab): '.$line);
}
if (! $this->option('no-report')) {
$path = sprintf('migration/grandfather-subscriptions-%s.json', now()->format('Ymd-His'));
Storage::put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->info("Report: storage/app/{$path}");
}
return self::SUCCESS;
}
/**
* Jüngste Archiv-Rechnung pro (Portal, Legacy-Vereinbarung) mit
* aktiver wiederkehrender Zahlungsoption.
*
* @return array<int, array<string, mixed>>
*/
private function collectLatestRecurringAgreements(): array
{
$latest = [];
LegacyInvoice::query()
->whereNotNull('user_id')
->whereNotNull('pdf_payload')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($invoices) use (&$latest): void {
foreach ($invoices as $invoice) {
$payload = $invoice->pdf_payload;
$upo = $payload['user_payment_option'] ?? null;
$option = $payload['payment_option'] ?? null;
if (! $upo || ! $option) {
continue;
}
if (($option['type'] ?? null) !== 'recurring' || ($upo['status'] ?? null) !== 'active') {
continue;
}
$key = $invoice->legacy_portal->value.'#'.$upo['id'];
$current = $latest[$key] ?? null;
if ($current && $current->invoice_date->greaterThanOrEqualTo($invoice->invoice_date)) {
continue;
}
$latest[$key] = $invoice;
}
});
return array_values(array_map(fn (LegacyInvoice $invoice): array => $this->toCandidate($invoice), $latest));
}
/**
* @return array<string, mixed>
*/
private function toCandidate(LegacyInvoice $invoice): array
{
$payload = $invoice->pdf_payload;
$snapshot = $invoice->raw_snapshot ?? [];
$upo = $payload['user_payment_option'];
$option = $payload['payment_option'];
$nextDue = isset($upo['next_due_date'])
? Carbon::parse($upo['next_due_date'])->startOfDay()
: Carbon::parse($snapshot['service_period_end_date'] ?? $invoice->invoice_date)->startOfDay();
$periodStart = isset($snapshot['service_period_begin_date'])
? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay()
: $nextDue->copy()->subYear();
return [
'user_id' => $invoice->user_id,
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_upo_id' => (int) $upo['id'],
'article_number' => (string) ($option['article_number'] ?? 'UNBEKANNT'),
'next_due_date' => $nextDue,
'period_start' => $periodStart,
'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date']
? Carbon::parse($upo['valid_until_date'])->startOfDay()
: null,
'legacy_conditions' => [
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_user_payment_option_id' => (int) $upo['id'],
'legacy_payment_option_id' => (int) ($option['id'] ?? 0),
'article_number' => $option['article_number'] ?? null,
'name' => $payload['payment_option_translation']['name'] ?? null,
'interval' => 'yearly',
'net_cents' => $this->deriveNetCents($invoice),
'last_total_cents' => $invoice->total_cents,
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
'source_invoice_number' => $invoice->number,
'source_invoice_date' => $invoice->invoice_date->toDateString(),
],
];
}
/**
* Netto-Vertragsbasis aus der letzten Legacy-Rechnung. Legacy fakturierte
* brutto (Steuer inkludiert, z. B. 199,00 €); steuerbefreite Kunden
* erhielten den Netto-Ausweis (`is_netto`, z. B. 167,23 €). Die neue
* Rechnungsstellung arbeitet immer auf Netto-Basis — die Steuer wird
* pro Rechnung über den VatResolver bestimmt.
*/
private function deriveNetCents(LegacyInvoice $invoice): int
{
$isNetto = (bool) (($invoice->raw_snapshot ?? [])['is_netto'] ?? false);
if ($isNetto) {
return $invoice->total_cents;
}
$vatRate = (float) config('billing.vat_rate', 0.19);
return (int) round($invoice->total_cents / (1 + $vatRate));
}
/**
* Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) — die
* verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions.
*/
private function resolveCatalogOption(array $candidate): PaymentOption
{
$portalShort = $candidate['legacy_portal'] === 'presseecho' ? 'PE' : 'BP';
$articleNumber = sprintf('LEGACY-%s-%s', $portalShort, $candidate['article_number']);
return PaymentOption::query()->firstOrCreate(
['article_number' => $articleNumber],
[
'type' => 'recurring',
// Katalogpreise sind netto (Entscheidung 12.06.2026).
'price_cents' => $candidate['legacy_conditions']['net_cents'],
'currency' => config('billing.default_currency'),
'interval' => 'yearly',
'is_hidden' => true,
],
);
}
private function findExisting(array $candidate): ?UserPaymentOption
{
return UserPaymentOption::query()
->where('user_id', $candidate['user_id'])
->get()
->first(function (UserPaymentOption $option) use ($candidate): bool {
$conditions = $option->legacy_conditions ?? [];
return ($conditions['legacy_portal'] ?? null) === $candidate['legacy_portal']
&& (int) ($conditions['legacy_user_payment_option_id'] ?? 0) === $candidate['legacy_upo_id'];
});
}
private function describe(array $candidate): string
{
return sprintf(
'User #%d · %s · Legacy-UPO #%d · fällig %s · netto %s €',
$candidate['user_id'],
$candidate['legacy_portal'],
$candidate['legacy_upo_id'],
$candidate['next_due_date']->toDateString(),
number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'),
);
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Importiert die Legacy-api_keys der Altportale als Sanctum-Tokens
* (Übernahme bestehender API-Integrationen, 06.07.2026). Quelle sind die
* read-only Legacy-Verbindungen; berechtigt ist, wer auch selbst ein Token
* erstellen dürfte (aktiver Zahlungsstatus/Bestandsschutz). Wiederholbar:
* bereits importierte Schlüssel werden übersprungen.
*
* Verwendung:
* php artisan legacy:import-api-keys --dry-run
* php artisan legacy:import-api-keys --portal=businessportal24
*/
class ImportLegacyApiKeys extends Command
{
protected $signature = 'legacy:import-api-keys
{--portal=all : presseecho | businessportal24 | all}
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Übernimmt die api_keys der Legacy-Portale als Sanctum-Tokens (bestehende Integrationen bleiben gültig).';
private const CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
public function handle(LegacyApiKeyImporter $importer): int
{
$portalOption = (string) $this->option('portal');
$dryRun = (bool) $this->option('dry-run');
$portals = $portalOption === 'all' ? array_keys(self::CONNECTIONS) : [$portalOption];
if (array_diff($portals, array_keys(self::CONNECTIONS)) !== []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
foreach ($portals as $portal) {
$rows = DB::connection(self::CONNECTIONS[$portal])
->table('sf_guard_user_profile')
->whereNotNull('api_key')
->where('api_key', '!=', '')
->get(['user_id', 'api_key'])
->map(fn (object $row): array => [
'legacy_id' => (int) $row->user_id,
'api_key' => (string) $row->api_key,
]);
$result = $importer->import($portal, $rows, $dryRun);
$this->table(
['Portal', 'Keys in Legacy-DB', 'Importiert', 'Schon vorhanden', 'Nicht berechtigt', 'Kein User-Mapping'],
[[
$portal,
$rows->count(),
$result['imported'],
$result['already_present'],
$result['ineligible'],
$result['unknown_user'],
]],
);
foreach ($result['ineligible_users'] as $email) {
$this->warn("Nicht berechtigt (kein aktiver Zahlungsstatus): {$email}");
}
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\LegacyBillingAddressImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Übernimmt die Rechnungsadressen der Bestandskunden aus dem Rechnungsarchiv
* (Befund 06.07.2026: ohne Adresse überspringt der MAN-Rechnungslauf die
* Vereinbarung und der Bestandsschutz läuft am Periodenende aus). Läuft
* NACH legacy:grandfather-subscriptions; die Legacy-Verbindungen werden nur
* für die Länderauflösung (country_id → Name) gebraucht. Wiederholbar:
* bestehende Adressen werden nie überschrieben.
*
* Verwendung:
* php artisan legacy:import-billing-addresses --dry-run
* php artisan legacy:import-billing-addresses
*/
class ImportLegacyBillingAddresses extends Command
{
protected $signature = 'legacy:import-billing-addresses
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Übernimmt Rechnungsadressen der grandfathered Bestandskunden aus dem Legacy-Rechnungsarchiv.';
private const CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
public function handle(LegacyBillingAddressImporter $importer): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$countryNamesByPortal = [];
foreach (self::CONNECTIONS as $portal => $connection) {
$countryNamesByPortal[$portal] = DB::connection($connection)
->table('country')
->pluck('name', 'id')
->map(fn ($name): string => (string) $name)
->all();
}
$report = $importer->import($countryNamesByPortal, $dryRun);
$this->table(
['Importiert', 'Adresse vorhanden', 'Nicht parsebar', 'Land unbekannt', 'Quell-Rechnung fehlt'],
[[
$report['imported'],
$report['skipped_existing'],
count($report['unparseable']),
count($report['unknown_country']),
count($report['missing_invoice']),
]],
);
foreach ($report['unparseable'] as $line) {
$this->warn("Manuell nachpflegen (Adresse nicht parsebar): {$line}");
}
foreach ($report['unknown_country'] as $line) {
$this->warn("Manuell nachpflegen (Land nicht zuordenbar): {$line}");
}
foreach ($report['missing_invoice'] as $email) {
$this->warn("Manuell prüfen (Quell-Rechnung nicht im Archiv): {$email}");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,177 @@
<?php
namespace App\Console\Commands;
use App\Services\Auth\UserRolePermissionSyncService;
use App\Services\Import\CategoryImporter;
use App\Services\Import\CompanyImporter;
use App\Services\Import\ContactImporter;
use App\Services\Import\ImportContext;
use App\Services\Import\ImportResult;
use App\Services\Import\PressReleaseImporter;
use App\Services\Import\UserAssociationLinker;
use App\Services\Import\UserImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Legacy-Import-Master-Command (Phase 6 – Datenmigration).
*
* Verwendung:
* php artisan legacy:import --source=presseecho --dry-run
* php artisan legacy:import --source=businessportal24 --step=users
* php artisan legacy:import --source=all --step=categories
* php artisan legacy:import --source=all --step=link-associations ← NEU
* php artisan legacy:import --source=presseecho --force
* php artisan legacy:import --source=presseecho --since=today --force ← Delta: nur heute Angelegtes/Geändertes
*
* Schritte (--step):
* categories – Kategorien + Translations (einmalig, beide Portale identisch)
* users – sf_guard_user + sf_guard_user_profile + Rollen
* companies – company + company_user + responsible_company_user
* contacts – contact
* press-releases – press_release + press_release_image + press_release_contact
* link-associations – User↔Kontakt direkt verknüpfen (via Firma-Zugehörigkeit)
* all – alle Schritte in korrekter Reihenfolge (Standard)
*/
class ImportLegacyData extends Command
{
protected $signature = 'legacy:import
{--source=all : Portal (presseecho|businessportal24|all)}
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
{--dry-run : Nichts schreiben, nur zählen}
{--force : Bereits importierte Datensätze erneut verarbeiten}
{--since= : Nur Legacy-Zeilen mit created_at/updated_at ab diesem Zeitpunkt (z. B. "2026-07-20", "today"); für Updates mit --force kombinieren}
{--chunk-size=500 : Batch-Größe (Debugging)}';
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
private const STEPS = [
'categories',
'users',
'companies',
'contacts',
'press-releases',
'link-associations',
];
private const PORTALS = ['presseecho', 'businessportal24'];
public function handle(UserRolePermissionSyncService $roleSync): int
{
$source = $this->option('source');
$step = $this->option('step');
$isDryRun = (bool) $this->option('dry-run');
$isForce = (bool) $this->option('force');
$since = null;
if (filled($this->option('since'))) {
try {
$since = Carbon::parse((string) $this->option('since'), config('app.timezone'));
} catch (\Throwable) {
$this->error('Ungültiger --since-Wert: '.$this->option('since'));
return self::FAILURE;
}
}
$portals = $source === 'all' ? self::PORTALS : [$source];
$steps = $step === 'all' ? self::STEPS : [$step];
if ($since) {
$this->info('Delta-Modus: nur Legacy-Zeilen mit created_at/updated_at ab '.$since->format('d.m.Y H:i:s').'.');
if (! $isForce) {
$this->warn('Hinweis: ohne --force werden im Zeitfenster GEÄNDERTE, bereits importierte Datensätze übersprungen — nur Neue kommen dazu.');
}
}
if (! $isDryRun && ! $this->option('force')) {
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
$this->line(' Portale: '.implode(', ', $portals));
$this->line(' Schritte: '.implode(', ', $steps));
$this->newLine();
if (! $this->confirm('Import starten?')) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
}
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalStart = microtime(true);
foreach ($steps as $currentStep) {
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
if ($currentStep === 'link-associations') {
$ctx = new ImportContext('all', $isDryRun, $isForce, $since);
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
$start = microtime(true);
$result = app(UserAssociationLinker::class)->run($ctx);
$elapsed = round(microtime(true) - $start, 1);
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
continue;
}
// Kategorien sind portal-übergreifend → nur einmal
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
foreach ($stepPortals as $portal) {
$ctx = new ImportContext($portal, $isDryRun, $isForce, $since);
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
$start = microtime(true);
$result = $this->runStep($currentStep, $ctx, $roleSync);
$elapsed = round(microtime(true) - $start, 1);
$this->line(" ✓ {$result->summary()} ({$elapsed}s)");
foreach (array_slice($result->errors(), 0, 10) as $err) {
$this->warn(" ! {$err}");
}
if (count($result->errors()) > 10) {
$this->warn(' ! ... und '.(count($result->errors()) - 10).' weitere Fehler.');
}
}
}
// Selbstheilung: Ein User-Insert (updateOrCreate) und die anschließende
// Rollen-Zuweisung sind nicht atomar – schlägt der Rollen-Sync nach dem
// Insert fehl, bliebe ein aktives, rollenloses Konto zurück (hängt beim
// Login auf der Startseite). Nach einem User-Import daher absichern.
if (! $isDryRun && in_array('users', $steps, true)) {
$this->newLine();
$this->info('▶ Nachlauf: aktive rollenlose Nutzer absichern');
$this->call('users:backfill-portal-role');
}
$total = round(microtime(true) - $totalStart, 1);
$this->newLine();
$this->info("Import abgeschlossen in {$total}s.");
return self::SUCCESS;
}
private function runStep(
string $step,
ImportContext $ctx,
UserRolePermissionSyncService $roleSync,
): ImportResult {
return match ($step) {
'categories' => app(CategoryImporter::class)->run($ctx),
'users' => app(UserImporter::class, ['roleSync' => $roleSync])->run($ctx),
'companies' => app(CompanyImporter::class)->run($ctx),
'contacts' => app(ContactImporter::class)->run($ctx),
'press-releases' => app(PressReleaseImporter::class)->run($ctx),
default => throw new \InvalidArgumentException("Unbekannter Schritt: {$step}"),
};
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Models\Company;
use App\Models\User;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Übernimmt die Namen der Legacy-Agentur-Firmen (companies.type = agency)
* in das Profilfeld users.agency_name des jeweiligen Owners — Teil des
* Umbaus Agentur-Attribution → User-Profil (dev/agentur-attribution/
* entwicklungsplan.md, A2). Läuft im Go-Live NACH dem Firmen-/User-Import
* und VOR companies:retire-agency-type. Wiederholbar: bereits gesetzte
* agency_name-Werte werden nie überschrieben.
*
* Verwendung:
* php artisan legacy:migrate-agency-names --dry-run
* php artisan legacy:migrate-agency-names
*/
class MigrateLegacyAgencyNames extends Command
{
protected $signature = 'legacy:migrate-agency-names
{--dry-run : Nur anzeigen und Report schreiben, keine User ändern}';
protected $description = 'Übernimmt Namen der Legacy-Agentur-Firmen in das Profilfeld users.agency_name.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
// Bewusst ohne is_active-Filter: die Attributions-Zeile hat auch
// inaktive Agentur-Firmen angezeigt; dieses Verhalten bleibt erhalten.
$agencies = Company::withoutGlobalScope(PortalScope::class)
->where('type', CompanyType::Agency)
->get(['id', 'portal', 'owner_user_id', 'name', 'is_active', 'updated_at']);
$report = [
'generated_at' => now()->toIso8601String(),
'dry_run' => $dryRun,
'summary' => [],
'multiple_names' => [],
'skipped_existing' => [],
'ownerless' => [],
'missing_owner' => [],
];
foreach ($agencies->whereNull('owner_user_id') as $orphan) {
$report['ownerless'][] = [
'company_id' => $orphan->id,
'portal' => $orphan->portal?->value,
'name' => $orphan->name,
];
}
$byOwner = $agencies->whereNotNull('owner_user_id')->groupBy('owner_user_id');
$users = User::withTrashed()->whereIn('id', $byOwner->keys())->get()->keyBy('id');
$migrated = 0;
foreach ($byOwner as $userId => $companies) {
$user = $users->get($userId);
if ($user === null) {
$report['missing_owner'][] = [
'user_id' => $userId,
'company_ids' => $companies->pluck('id')->all(),
];
continue;
}
/** @var Company $winner Zuletzt aktualisierter Datensatz gewinnt (Regel Mehrfach-Fälle). */
$winner = $companies->sortByDesc('updated_at')->first();
$variants = $companies->pluck('name')->unique()->values();
if ($variants->count() > 1) {
$report['multiple_names'][] = [
'user_id' => $user->id,
'email' => $user->email,
'chosen' => $winner->name,
'variants' => $variants->all(),
];
}
if (filled($user->agency_name)) {
$report['skipped_existing'][] = [
'user_id' => $user->id,
'email' => $user->email,
'existing' => $user->agency_name,
'candidate' => $winner->name,
];
continue;
}
if (! $dryRun) {
$user->update(['agency_name' => $winner->name]);
}
$migrated++;
}
$report['summary'] = [
'agency_companies' => $agencies->count(),
'users_migrated' => $migrated,
'users_skipped_existing' => count($report['skipped_existing']),
'users_multiple_names' => count($report['multiple_names']),
'companies_ownerless' => count($report['ownerless']),
'owners_missing' => count($report['missing_owner']),
];
$this->table(
['Agentur-Firmen', 'User migriert', 'Bereits gesetzt', 'Mehrfach-Namen', 'Ohne Owner', 'Owner fehlt'],
[array_values($report['summary'])],
);
$path = 'migration/agency-names-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->line("Report geschrieben: storage/app/private/{$path}");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,472 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateLegacyMedia extends Command
{
protected $signature = 'legacy:migrate-media
{--portal=all : Portal (presseecho|businessportal24|all)}
{--type=all : Medientyp (company-logos|press-release-images|all)}
{--base-path=dev/migration : Lokaler Ordner mit den Legacy-Dateien}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Bereits migrierte Dateien erneut überschreiben}
{--limit=0 : Maximal N Legacy-Datensätze pro Typ und Portal verarbeiten (0 = alle)}';
protected $description = 'Migriert Legacy-Firmenlogos und Pressemitteilungsbilder anhand der Legacy-DB in den finalen Storage.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/**
* @var array<string, list<string>>
*/
private const SOURCE_DIRECTORIES = [
'company-logos' => [
'uploads/company',
'thumbnails/company',
],
'press-release-images' => [
'uploads/pressreleaseimage',
'uploads/pressreleaseimage_',
'uploads/_pressreleaseimage',
'thumbnails/pressreleaseimage',
],
];
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$portals = $this->selectedPortals();
$types = $this->selectedTypes();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
if ($types === []) {
$this->error('Ungültiger Medientyp. Erlaubt: company-logos, press-release-images, all.');
return self::FAILURE;
}
$configuredBasePath = (string) $this->option('base-path');
$basePath = Str::startsWith($configuredBasePath, '/')
? rtrim($configuredBasePath, '/')
: base_path(trim($configuredBasePath, '/'));
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$totals = $this->emptyStats();
foreach ($portals as $portal) {
foreach ($types as $type) {
$stats = match ($type) {
'company-logos' => $this->migrateCompanyLogos($portal, $basePath, $dryRun, $force, $limit),
'press-release-images' => $this->migratePressReleaseImages($portal, $basePath, $dryRun, $force, $limit),
};
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s/%s: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d',
$portal,
$type,
$stats['legacy_rows'],
$stats['migrated'],
$stats['thumbnail_fallback'],
$stats['updated'],
$stats['already_synced'],
$stats['missing_target'],
$stats['missing_file'],
));
}
}
$this->newLine();
$this->info(sprintf(
'Gesamt: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d%s',
$totals['legacy_rows'],
$totals['migrated'],
$totals['thumbnail_fallback'],
$totals['updated'],
$totals['already_synced'],
$totals['missing_target'],
$totals['missing_file'],
$dryRun ? ' (Dry-Run)' : '',
));
return ($totals['missing_target'] + $totals['missing_file']) > 0
? self::FAILURE
: self::SUCCESS;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migrateCompanyLogos(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'company-logos');
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('company')
->whereNotNull('logo')
->where('logo', '!=', '')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
$processed++;
$stats['legacy_rows']++;
$company = Company::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first(['id', 'logo_path', 'logo_variants']);
if (! $company) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/company/{$row->id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->logo);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
if ($this->isAlreadySynced($company->logo_path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($company->logo_variants) || $company->logo_variants === [])) {
$variants = $this->imageService->generateMissingCompanyLogoVariants($destinationPath);
if ($variants !== []) {
$company->forceFill(['logo_variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
continue;
}
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$company->forceFill([
'logo_path' => $destinationPath,
'logo_variants' => $this->imageService->generateMissingCompanyLogoVariants($destinationPath),
])->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migratePressReleaseImages(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'press-release-images');
$thumbnailIndex = $this->thumbnailIndex($basePath, $portal);
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('press_release_image')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $thumbnailIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
if (blank($row->image)) {
continue;
}
$processed++;
$stats['legacy_rows']++;
$pressRelease = PressRelease::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->press_release_id)
->first(['id']);
if (! $pressRelease) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/press_release/{$row->press_release_id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->image);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$usedThumbnailFallback = false;
if (! $sourcePath) {
$sourcePath = $thumbnailIndex[(int) $row->id] ?? null;
$usedThumbnailFallback = $sourcePath !== null;
}
$destinationFilename = $usedThumbnailFallback ? basename($sourcePath) : $sourceFilename;
$destinationPath = "press-releases/{$pressRelease->id}/images/{$destinationFilename}";
$image = PressReleaseImage::withTrashed()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first();
if ($image && $this->isAlreadySynced($image->path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($image->variants) || $image->variants === [])) {
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
if ($variants !== []) {
$image->forceFill(['variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/press_release_image/{$sourceFilename} (Image #{$row->id})");
continue;
}
if ($usedThumbnailFallback) {
$stats['thumbnail_fallback']++;
}
$variants = [];
$size = [null, null];
$mime = null;
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
$mime = File::mimeType($sourcePath) ?: null;
$image = $image ?: new PressReleaseImage;
$image->forceFill([
'press_release_id' => $pressRelease->id,
'disk' => 'public',
'path' => $destinationPath,
'variants' => $variants,
'title' => $row->title ?: null,
'description' => $row->description ?: null,
'copyright' => $row->copyright ?: null,
'is_preview' => (bool) $row->is_preview_image,
'sort_order' => 0,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'mime' => $mime,
'legacy_portal' => $portal,
'legacy_id' => $row->id,
]);
if ($image->trashed()) {
$image->restore();
}
$image->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return list<string>
*/
private function selectedTypes(): array
{
$type = (string) $this->option('type');
return match ($type) {
'all' => ['company-logos', 'press-release-images'],
'company-logos', 'press-release-images' => [$type],
default => [],
};
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function emptyStats(): array
{
return [
'legacy_rows' => 0,
'migrated' => 0,
'thumbnail_fallback' => 0,
'updated' => 0,
'already_synced' => 0,
'missing_target' => 0,
'missing_file' => 0,
];
}
/**
* @return array<string, string>
*/
private function sourceIndex(string $basePath, string $portal, string $type): array
{
$index = [];
foreach (self::SOURCE_DIRECTORIES[$type] as $relativeDirectory) {
$directory = "{$basePath}/{$portal}/{$relativeDirectory}";
if (! File::isDirectory($directory)) {
continue;
}
foreach (File::allFiles($directory) as $file) {
$index[$file->getFilename()] ??= $file->getPathname();
$index[Str::lower($file->getFilename())] ??= $file->getPathname();
}
}
return $index;
}
/**
* @return array<int, string>
*/
private function thumbnailIndex(string $basePath, string $portal): array
{
$directory = "{$basePath}/{$portal}/thumbnails/pressreleaseimage";
if (! File::isDirectory($directory)) {
return [];
}
$index = [];
$priorities = [
'press_image_preview' => 50,
'pressrelease_form' => 40,
'backend_list' => 30,
'press_image_list' => 20,
'press_list_image' => 10,
];
foreach (File::allFiles($directory) as $file) {
if (! preg_match('/-(\d+)\.[^.]+$/', $file->getFilename(), $matches)) {
continue;
}
$legacyImageId = (int) $matches[1];
$thumbnailType = Str::after($file->getPathname(), "{$directory}/");
$thumbnailType = Str::before($thumbnailType, DIRECTORY_SEPARATOR);
$priority = $priorities[$thumbnailType] ?? 0;
$current = $index[$legacyImageId] ?? null;
if (! $current || $priority > ($current['priority'] ?? 0)) {
$index[$legacyImageId] = [
'path' => $file->getPathname(),
'priority' => $priority,
];
}
}
return collect($index)
->map(fn (array $entry): string => $entry['path'])
->all();
}
private function legacyFilename(string $path): string
{
$urlPath = parse_url($path, PHP_URL_PATH);
$filename = basename(rawurldecode((string) ($urlPath ?: $path)));
return Str::of($filename)->trim()->toString();
}
private function isAlreadySynced(?string $currentPath, string $destinationPath, bool $force): bool
{
return ! $force
&& $currentPath === $destinationPath
&& Storage::disk('public')->exists($destinationPath);
}
private function copyToPublicStorage(string $sourcePath, string $destinationPath, bool $force): void
{
if (! $force && Storage::disk('public')->exists($destinationPath)) {
return;
}
$stream = fopen($sourcePath, 'rb');
if ($stream === false) {
throw new \RuntimeException("Quelldatei kann nicht gelesen werden: {$sourcePath}");
}
try {
Storage::disk('public')->put($destinationPath, $stream, 'public');
} finally {
fclose($stream);
}
}
}

View file

@ -2,21 +2,19 @@
namespace App\Console\Commands;
use App\Models\Article as NewArticle;
use App\Models\Brand;
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
use App\Models\Legacy\Presseecho\User as LegacyUser;
// Import der Models für die NEUE Struktur
use App\Models\User as NewUser;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
// Import der Models für die ALTE Struktur
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
// Import der Models für die NEUE Struktur
use App\Models\User as NewUser;
use App\Models\Article as NewArticle;
use App\Models\Brand;
// Import der Models für die ALTE Struktur
use App\Models\Legacy\Presseecho\User as LegacyUser;
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
class MigratePresseechoData extends Command
class MigratePresseData extends Command
{
/**
* The name and signature of the console command.
@ -41,8 +39,9 @@ class MigratePresseechoData extends Command
// Holen der Brand-ID für Presseecho aus der neuen DB
$brand = Brand::where('domain', 'presseecho.de')->first();
if (!$brand) {
if (! $brand) {
$this->error('Brand "presseecho.de" not found in the new database. Please seed brands first.');
return 1;
}
@ -58,6 +57,7 @@ class MigratePresseechoData extends Command
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
$this->info('Migration for presseecho.de completed successfully!');
return 0;
}
@ -73,7 +73,7 @@ class MigratePresseechoData extends Command
// Prüfen, ob der Nutzer (anhand der E-Mail) bereits in der neuen DB existiert
$existingUser = NewUser::where('email', $legacyUser->email)->first();
if (!$existingUser) {
if (! $existingUser) {
// Nutzer existiert noch nicht -> neu anlegen
NewUser::create([
'name' => $legacyUser->name,
@ -104,8 +104,9 @@ class MigratePresseechoData extends Command
// Den zugehörigen Nutzer in der NEUEN Datenbank finden
$author = NewUser::where('email', $legacyArticle->author_email)->first(); // Annahme: Artikel hat eine Autoren-E-Mail
if (!$author) {
if (! $author) {
$this->warn("Skipping article ID {$legacyArticle->id}, author with email {$legacyArticle->author_email} not found.");
continue; // Artikel überspringen, wenn kein Autor gefunden wurde
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\PressReleaseMediaPrivatizer;
use Illuminate\Console\Command;
class PrivatizePressReleaseMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'press-releases:privatize-media
{--prune-missing : Unmigrierte Legacy-Bildzeilen ohne Datei endgültig löschen}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verschiebt bestehende Pressemitteilungs-Medien vom öffentlichen auf den privaten Datenträger';
/**
* Execute the console command.
*/
public function handle(PressReleaseMediaPrivatizer $privatizer): int
{
$counts = $privatizer->privatizeAll((bool) $this->option('prune-missing'));
$this->info(sprintf(
'%d Bilder und %d Anhänge wurden auf den privaten Datenträger migriert.',
$counts['images'],
$counts['attachments'],
));
if ($counts['legacy_pruned'] > 0) {
$this->info(sprintf(
'%d unmigrierte Legacy-Bildzeilen ohne Datei wurden gelöscht.',
$counts['legacy_pruned'],
));
}
if ($counts['legacy_skipped'] > 0) {
$this->warn(sprintf(
'%d unmigrierte Legacy-Bildzeilen übersprungen — erst `legacy:migrate-media` ausführen, danach Verbliebene ohne Datei mit `--prune-missing` löschen.',
$counts['legacy_skipped'],
));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Throwable;
/**
* Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation
* `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
*
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
* berührt nur grüne PRs in Review-Status — bereits publishte werden ignoriert.
* Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn
* ihr Termin fällig ist.
*
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
* Mail-Benachrichtigung des Autors.
*/
class PublishScheduledPressReleases extends Command
{
/**
* @var string
*/
protected $signature = 'press-releases:publish-scheduled
{--dry-run : Nur anzeigen, was publiziert würde, ohne DB zu ändern}
{--limit=200 : Maximale Anzahl pro Lauf}';
/**
* @var string
*/
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
public function handle(PressReleaseService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$now = now();
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,
PressReleaseClassification::Yellow->value,
])
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now)
->orderBy('scheduled_at')
->limit($limit)
->get();
if ($candidates->isEmpty()) {
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
return self::SUCCESS;
}
$this->info(sprintf(
'%d fällige Pressemitteilung(en) gefunden.%s',
$candidates->count(),
$dryRun ? ' (Dry-Run)' : '',
));
$published = 0;
$rejected = 0;
$failed = 0;
foreach ($candidates as $pressRelease) {
$line = sprintf(
' #%d scheduled_at=%s title="%s"',
$pressRelease->id,
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
Str::limit($pressRelease->title, 60),
);
if ($dryRun) {
$this->line($line.' [DRY]');
continue;
}
try {
$service->publish($pressRelease, source: 'scheduler');
$published++;
$this->line($line.' [OK]');
} catch (BlacklistViolationException $e) {
$rejected++;
$this->warn($line.' [REJECT: '.$e->word.']');
} catch (Throwable $e) {
$failed++;
$this->error($line.' [FAIL: '.$e->getMessage().']');
report($e);
}
}
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
$published,
$rejected,
$failed,
));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Console\Command;
/**
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
*/
class PurgeExpiredPressReleaseDrafts extends Command
{
protected $signature = 'press-releases:purge-drafts
{--days=180 : Entwürfe älter als X Tage archivieren}
{--dry-run : Nur zählen, nichts ändern}';
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
public function handle(): int
{
$days = (int) $this->option('days');
$isDryRun = $this->option('dry-run');
$cutoff = now()->subDays($days);
$query = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Draft->value)
->where('updated_at', '<', $cutoff);
$count = $query->count();
if ($isDryRun) {
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
return self::SUCCESS;
}
$query->update(['status' => PressReleaseStatus::Archived->value]);
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\MagicLink;
use Illuminate\Console\Command;
/**
* Löscht verbrauchte oder abgelaufene Magic-Links, die älter als 30 Tage sind.
* Läuft täglich via Scheduler.
*/
class PurgeMagicLinks extends Command
{
protected $signature = 'magic-links:purge {--days=30 : Links älter als X Tage löschen}';
protected $description = 'Löscht verbrauchte und abgelaufene Magic-Links.';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$deleted = MagicLink::query()
->where(function ($q) use ($cutoff): void {
$q->where('expires_at', '<', $cutoff)
->orWhere(function ($q) use ($cutoff): void {
$q->whereNotNull('consumed_at')
->where('consumed_at', '<', $cutoff);
});
})
->delete();
$this->info("Magic-Links bereinigt: {$deleted} Einträge gelöscht.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Web\MarketDataService;
use Illuminate\Console\Command;
class RefreshMarketData extends Command
{
protected $signature = 'market:refresh';
protected $description = 'Kursdaten (DAX, EUR/USD, Brent) für den Web-Ticker abrufen und cachen';
public function handle(MarketDataService $marketData): int
{
if (! config('market.enabled')) {
$this->info('Marktdaten sind deaktiviert (MARKET_DATA_ENABLED=false).');
return self::SUCCESS;
}
$slides = $marketData->refresh();
if ($slides === []) {
$this->warn('Keine Kursdaten erhalten — Cache unverändert.');
return self::FAILURE;
}
foreach ($slides as $slide) {
$this->line(sprintf('%s: %s (%s)', $slide['label'], $slide['value'], $slide['delta']));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace App\Console\Commands;
use App\Services\Admin\AdminSlowRequestReporter;
use Illuminate\Console\Command;
class ReportAdminSlowRequests extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:slow-requests
{--from= : Startzeitpunkt, z.B. 2026-04-29 oder 2026-04-29 08:00:00}
{--to= : Endzeitpunkt}
{--route= : Auf Route-Namen filtern}
{--path= : Auf Pfad filtern}
{--status= : Auf HTTP-Statuscode filtern}
{--min-duration= : Mindestdauer in Millisekunden}
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
{--limit=25 : Anzahl Detailzeilen}
{--file=* : Optionaler Logdatei-Pfad oder Glob}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Wertet Slow-Admin-Request-Logs aus.';
/**
* Execute the console command.
*/
public function handle(AdminSlowRequestReporter $reporter): int
{
$top = max(1, (int) $this->option('top'));
$limit = max(1, (int) $this->option('limit'));
$files = array_values(array_filter(array_map('strval', (array) $this->option('file'))));
$report = $reporter->report(
filters: [
'from' => $this->option('from') !== null ? (string) $this->option('from') : null,
'to' => $this->option('to') !== null ? (string) $this->option('to') : null,
'route' => $this->option('route') !== null ? (string) $this->option('route') : null,
'path' => $this->option('path') !== null ? (string) $this->option('path') : null,
'status' => $this->option('status') !== null ? (int) $this->option('status') : null,
'min_duration_ms' => $this->option('min-duration') !== null ? (int) $this->option('min-duration') : null,
],
top: $top,
limit: $limit,
paths: $files !== [] ? $files : null,
);
$this->info('Slow-Admin-Request-Report');
$this->newLine();
$this->line("Dateien: {$report['summary']['files']}");
$this->line("Requests: {$report['summary']['total_requests']}");
$this->line("Routen: {$report['summary']['unique_routes']}");
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
$this->line("Max. Dauer: {$report['summary']['max_duration_ms']} ms");
$this->line("Durchschnitt DB-Zeit: {$report['summary']['average_database_time_ms']} ms");
$this->line("Max. Query-Anzahl: {$report['summary']['max_query_count']}");
$this->renderTopTable('Top Routen', $report['top_routes']);
$this->renderTopTable('Top Pfade', $report['top_paths']);
$this->renderTopTable('Statuscodes', $report['status_codes']);
$this->renderRequests('Langsamste Requests', $report['slowest_requests']);
$this->renderRequests('Query-lastige Requests', $report['query_heavy_requests']);
$this->renderSlowQueries($report['slow_queries']);
$this->renderExplainPlans($report['explain_plans']);
return self::SUCCESS;
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderTopTable(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Wert', 'Requests', 'Ø Dauer', 'Max Dauer', 'Ø DB', 'Queries'],
collect($rows)
->map(fn (array $row): array => [
$row['value'],
$row['requests'],
$row['average_duration_ms'].' ms',
$row['max_duration_ms'].' ms',
$row['average_database_time_ms'].' ms',
$row['total_queries'],
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderRequests(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Zeit', 'Route', 'Pfad', 'Status', 'Dauer', 'DB', 'Queries'],
collect($rows)
->map(fn (array $row): array => [
$row['timestamp'],
$row['route_name'],
$row['path'],
$row['status_code'],
$row['duration_ms'].' ms',
$row['database_time_ms'].' ms',
$row['query_count'],
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderSlowQueries(array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line('Häufige Slow Queries');
$this->table(
['SQL', 'Vorkommen', 'Ø Zeit', 'Max Zeit'],
collect($rows)
->map(fn (array $row): array => [
str($row['sql'])->limit(100)->toString(),
$row['occurrences'],
$row['average_time_ms'].' ms',
$row['max_time_ms'].' ms',
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderExplainPlans(array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line('EXPLAIN Top Slow Queries');
foreach ($rows as $row) {
$this->line(str($row['sql'])->limit(120)->toString());
if ($row['error'] !== null) {
$this->warn((string) $row['error']);
continue;
}
$this->table(
array_keys($row['plan'][0] ?? []),
collect($row['plan'])->map(fn (array $planRow): array => array_values($planRow))->all()
);
}
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\ApiUsageReporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ReportApiUsage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:usage-report
{--from= : Startzeitpunkt, z.B. 2026-04-01 oder 2026-04-01 00:00:00}
{--to= : Endzeitpunkt}
{--user= : Auf User-ID filtern}
{--status= : Auf HTTP-Statuscode filtern}
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Erstellt einen Report aus den protokollierten API-Usage-Logs.';
/**
* Execute the console command.
*/
public function handle(ApiUsageReporter $reporter): int
{
$top = max(1, (int) $this->option('top'));
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
$statusCode = $this->option('status') !== null ? (int) $this->option('status') : null;
$report = $reporter->report(
from: $this->option('from') !== null ? (string) $this->option('from') : null,
to: $this->option('to') !== null ? (string) $this->option('to') : null,
userId: $userId,
statusCode: $statusCode,
top: $top,
);
$this->info('API-Usage-Report');
$this->newLine();
$this->line("Requests: {$report['summary']['total_requests']}");
$this->line("Eindeutige User: {$report['summary']['unique_users']}");
$this->line("Eindeutige Tokens: {$report['summary']['unique_tokens']}");
$this->line("2xx: {$report['summary']['successful_requests']}");
$this->line("4xx: {$report['summary']['client_error_requests']}");
$this->line("5xx: {$report['summary']['server_error_requests']}");
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
$this->renderRows('Top Pfade', ['Pfad', 'Requests'], $report['top_paths']);
$this->renderRows('Statuscodes', ['Status', 'Requests'], $report['status_codes']);
$this->renderRows('Top User', ['User-ID', 'Requests'], $report['top_users']);
$this->renderRows('Top Tokens', ['Token-ID', 'Requests'], $report['top_tokens']);
if (! (bool) $this->option('no-report')) {
$path = 'migration/api-usage-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
/**
* @param list<string> $headers
* @param list<array{value: mixed, requests: int}> $rows
*/
private function renderRows(string $title, array $headers, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
$headers,
collect($rows)
->map(fn (array $row): array => [$row['value'], $row['requests']])
->all()
);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiCustomerReporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ReportLegacyApiCustomers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:legacy-customers-report
{--portal=all : Portal (presseecho|businessportal24|both|all)}
{--classification=all : Filter (eligible|needs_review|blocked|all)}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Ermittelt Legacy-API-Kunden aus migrierten Daten und letzter bezahlter Rechnung.';
/**
* Execute the console command.
*/
public function handle(LegacyApiCustomerReporter $reporter): int
{
$portal = (string) $this->option('portal');
$classification = (string) $this->option('classification');
if (! in_array($portal, ['presseecho', 'businessportal24', 'both', 'all'], true)) {
$this->error("Unbekanntes Portal: {$portal}");
return self::FAILURE;
}
if (! in_array($classification, ['eligible', 'needs_review', 'blocked', 'all'], true)) {
$this->error("Unbekannte Klassifizierung: {$classification}");
return self::FAILURE;
}
$report = $reporter->report($portal);
$customers = collect($report['customers']);
if ($classification !== 'all') {
$customers = $customers
->where('classification', $classification)
->values();
}
$this->info('Legacy-API-Kundenreport');
$this->newLine();
$this->line("Portal: {$portal}");
$this->line("Kandidaten: {$report['summary']['total_candidates']}");
$this->line("Freigabefähig: {$report['summary']['eligible']}");
$this->line("Manuell prüfen: {$report['summary']['needs_review']}");
$this->line("Gesperrt: {$report['summary']['blocked']}");
if ($customers->isNotEmpty()) {
$this->newLine();
$this->table(
['ID', 'E-Mail', 'Portal', 'Letzte Rechnung', 'Status', 'Klassifizierung'],
$customers
->take(25)
->map(fn (array $customer): array => [
$customer['user_id'],
$customer['email'],
$customer['portal'],
$customer['latest_legacy_invoice']['number'] ?? '-',
$customer['latest_legacy_invoice']['status'] ?? '-',
$customer['classification'],
])
->all()
);
}
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-api-customers-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
*
* Fester Bestandteil des Tarif-Modells (geprüft R1.3, 03.07.2026): Die
* Plan-Kontingente (§2/§3.3 Decision-Update) gelten pro Kalendermonat;
* User::pressReleaseQuotaRemaining() rechnet gegen diesen Zähler. Läuft
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
* Abo-Periode ausgerichtet.
*/
class ResetMonthlyPressReleaseQuota extends Command
{
/**
* @var string
*/
protected $signature = 'press-releases:reset-monthly-quota';
/**
* @var string
*/
protected $description = 'Setzt den monatlichen PM-Kontingent-Verbrauch (press_release_quota_used_this_month) aller User auf 0 zurück.';
public function handle(): int
{
$affected = User::query()
->where('press_release_quota_used_this_month', '>', 0)
->update(['press_release_quota_used_this_month' => 0]);
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Models\Company;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage;
/**
* Schafft den Firmen-Typ „Agentur" ab (dev/agentur-attribution/
* entwicklungsplan.md, A4): Agentur-Firmen mit PMs oder Pressekontakten
* werden zu normalen Firmen umtypisiert (sie sind faktisch Newsrooms),
* leere Agentur-Firmen werden soft-gelöscht. Nie gelöscht wird, wenn
* Dritte per company_user Mitglied sind oder eine Zahlungsoptions-
* Verknüpfung (user_payment_option_company) existiert — dann wird nur
* umtypisiert. Läuft im Go-Live NACH legacy:migrate-agency-names.
*
* Verwendung:
* php artisan companies:retire-agency-type --dry-run
* php artisan companies:retire-agency-type
*/
class RetireAgencyCompanyType extends Command
{
protected $signature = 'companies:retire-agency-type
{--dry-run : Nur anzeigen und Report schreiben, keine Firmen ändern}';
protected $description = 'Typisiert Legacy-Agentur-Firmen zu normalen Firmen um bzw. soft-löscht leere Agentur-Firmen.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$agencies = Company::withoutGlobalScope(PortalScope::class)
->where('type', CompanyType::Agency)
->withExists([
// PMs inkl. soft-gelöschter und portalfremder Datensätze —
// eine Firma mit irgendeiner PM-Historie bleibt Newsroom.
'pressReleases as has_press_releases' => fn (Builder $query) => $query->withoutGlobalScopes(),
'contacts as has_contacts',
'userPaymentOptions as has_payment_option_links',
// Mitgliedschaften Dritter: jeder Pivot-Eintrag, der nicht der
// Owner selbst ist (bei Owner-losen Firmen jeder Eintrag).
'users as has_third_party_members' => fn (Builder $query) => $query->where(
fn (Builder $query) => $query
->whereColumn('company_user.user_id', '!=', 'companies.owner_user_id')
->orWhereNull('companies.owner_user_id'),
),
])
->get();
$report = [
'generated_at' => now()->toIso8601String(),
'dry_run' => $dryRun,
'summary' => [],
'retyped' => [],
'deleted' => [],
'protected' => [],
];
foreach ($agencies as $agency) {
$entry = [
'company_id' => $agency->id,
'portal' => $agency->portal?->value,
'name' => $agency->name,
];
$isNewsroom = $agency->has_press_releases || $agency->has_contacts;
$isProtected = $agency->has_third_party_members || $agency->has_payment_option_links;
if (! $isNewsroom && ! $isProtected) {
if (! $dryRun) {
$agency->delete();
}
$report['deleted'][] = $entry;
continue;
}
if (! $isNewsroom && $isProtected) {
$report['protected'][] = $entry + [
'third_party_members' => (bool) $agency->has_third_party_members,
'payment_option_links' => (bool) $agency->has_payment_option_links,
];
}
if (! $dryRun) {
$agency->update(['type' => CompanyType::Company]);
}
$report['retyped'][] = $entry;
}
$report['summary'] = [
'agency_companies' => $agencies->count(),
'retyped' => count($report['retyped']),
'deleted' => count($report['deleted']),
'protected' => count($report['protected']),
];
$this->table(
['Agentur-Firmen', 'Umtypisiert', 'Soft-gelöscht', 'Geschützt (nur umtypisiert)'],
[array_values($report['summary'])],
);
$path = 'migration/retire-agency-type-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->line("Report geschrieben: storage/app/private/{$path}");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Arbeitet die KI-Klassifikations-Queue zum Testen einmalig ab – ohne
* dauerhaft laufenden Queue-Worker.
*
* Standardmäßig werden alle anstehenden Jobs verarbeitet und der Worker
* beendet sich danach (--stop-when-empty). Mit --once nur ein einzelner Job.
*/
class RunClassificationQueue extends Command
{
protected $signature = 'classification:work {--once : Nur einen einzelnen Job verarbeiten}';
protected $description = 'Verarbeitet die KI-Klassifikations-Queue einmalig (ohne Dauer-Worker).';
public function handle(): int
{
$options = [
'--queue' => 'classification',
'--tries' => 3,
];
if ($this->option('once')) {
$options['--once'] = true;
} else {
$options['--stop-when-empty'] = true;
}
return $this->call('queue:work', $options);
}
}

View file

@ -0,0 +1,213 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyType;
use App\Enums\PaymentOptionType;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Permission;
/**
* Legt einen API-Testkunden nach connektar-Muster an (S4 der Paritäts-Doku
* dev/betrieb/api-legacy-paritaet.md): aktiver Bestandskunde mit Grandfathering,
* importiertem Legacy-Key, Sofort-Publish-Recht, Firma + Pressekontakt — und
* gibt Key + fertige curl-Sammlung für den /service-Flow aus.
*
* Idempotent: wiederholte Läufe erneuern nur den API-Key. Nur local/staging.
*/
class SeedLegacyApiTestdata extends Command
{
protected $signature = 'legacy:seed-api-testdata
{--portal=businessportal24 : Portal des Testkunden (businessportal24|presseecho)}
{--email=api-test@pressekonto.test : E-Mail des Testkunden}';
protected $description = 'API-Testdaten nach connektar-Muster anlegen (Legacy-Key, Firma, Kontakt) + curl-Sammlung ausgeben.';
public function handle(LegacyApiKeyImporter $importer): int
{
if (app()->environment('production')) {
$this->error('Nur fuer local/staging — nicht in Produktion ausfuehren.');
return self::FAILURE;
}
$portal = (string) $this->option('portal');
if (! in_array($portal, [Portal::Businessportal24->value, Portal::Presseecho->value], true)) {
$this->error('Ungueltiges Portal: '.$portal);
return self::FAILURE;
}
$email = (string) $this->option('email');
$legacyId = 999901;
// 1. Testkunde: aktiver Legacy-API-User (wie connektar).
$user = User::query()->firstOrCreate(
['email' => $email],
[
'name' => 'API Testkunde',
'password' => Hash::make('password'),
'email_verified_at' => now(),
],
);
$user->forceFill([
'is_active' => true,
'portal' => $portal,
'legacy_portal' => $portal,
'legacy_id' => $legacyId,
'registration_type' => RegistrationType::ApiUser,
'agency_name' => 'API Testverteiler',
])->save();
if (! $user->hasRole('customer')) {
$user->assignRole('customer');
}
// 2. Grandfathering (Bestandsschutz): macht den User API-berechtigt
// und laesst das Buchungs-Gate passieren.
$paymentOption = PaymentOption::query()->firstOrCreate(
['article_number' => 'ART-API-TEST'],
[
'type' => PaymentOptionType::cases()[0]->value,
'price_cents' => 0,
'currency' => 'EUR',
'interval' => 'monthly',
'is_hidden' => true,
],
);
UserPaymentOption::query()->updateOrCreate(
['user_id' => $user->id, 'payment_option_id' => $paymentOption->id],
[
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'current_period_start' => now()->subMonth(),
'current_period_end' => now()->addMonths(11),
'stripe_subscription_id' => null,
],
);
// 3. Sofort-Publish-Privileg (Alt-Verhalten von connektar). Die
// Permission defensiv anlegen — lokale DBs sind nicht immer auf dem
// juengsten Seeder-Stand.
Permission::firstOrCreate(['name' => 'api:immediate-publication']);
if (! $user->hasPermissionTo('api:immediate-publication')) {
$user->givePermissionTo('api:immediate-publication');
}
// 4. Firma + Pressekontakt (der API-Flow kann weitere anlegen).
$company = Company::withoutGlobalScopes()->firstOrNew(
['name' => 'API Test GmbH', 'portal' => $portal],
[
'type' => CompanyType::Company->value,
'address' => "Teststrasse 1\n33602 Bielefeld",
'country_code' => 'DE',
'email' => 'firma@api-test.example',
'website' => 'https://api-test.example',
'is_active' => true,
],
);
if (! $company->slug) {
$company->slug = $company->generateUniqueSlug($company->name);
}
$company->owner_user_id ??= $user->id;
$company->save();
if (! $company->users()->whereKey($user->id)->exists()) {
$company->users()->attach($user->id, ['role' => 'owner']);
}
$contact = $company->contacts()->firstOrCreate(
['email' => 'presse@api-test.example'],
[
'portal' => $portal,
'salutation_key' => 'mr',
'first_name' => 'Max',
'last_name' => 'Mustermann',
'responsibility' => 'Pressestelle',
],
);
// 5. Legacy-API-Key erneuern (wie legacy:import-api-keys).
$apiKey = 'test-'.Str::random(40);
$user->tokens()->delete();
$importer->import($portal, [
['legacy_id' => $legacyId, 'api_key' => $apiKey],
]);
$category = Category::withoutGlobalScopes()->where('is_active', true)->first();
$host = rtrim((string) config("domains.domains.{$portal}.url", config('app.url')), '/');
$this->info('API-Testkunde bereit.');
$this->table(['Feld', 'Wert'], [
['Login (Panel)', $email.' / password'],
['Portal', $portal],
['X-ApiKey', $apiKey],
['Firma', "#{$company->id} {$company->name}"],
['Pressekontakt', "#{$contact->id} {$contact->first_name} {$contact->last_name}"],
['Kategorie (legacy_id)', $category ? "#{$category->legacy_id}" : '— (keine aktive Kategorie!)'],
['Sofort-Publish', 'ja (api:immediate-publication)'],
]);
$categoryId = $category?->legacy_id ?? 1;
$this->line(<<<CURL
# --- connektar-Flow (Alt-Vertrag, dev/betrieb/api-legacy-paritaet.md §1) ---
# 1. Firma anlegen
curl -s -i -X POST '{$host}/service/users/me/companies' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'company[name]=Neue Testfirma GmbH' \\
-d 'company[address]=Musterweg 2, 33602 Bielefeld' \\
-d 'company[country_id]=177' \\
-d 'company[email]=info@neue-testfirma.example'
# 2. Pressekontakt anlegen (COMPANY_ID aus Location-Header von Schritt 1)
curl -s -i -X POST '{$host}/service/users/me/companies/COMPANY_ID/contacts' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'contact[salutation_id]=1' \\
-d 'contact[first_name]=Erika' \\
-d 'contact[last_name]=Beispiel' \\
-d 'contact[email]=presse@neue-testfirma.example'
# 3. PM anlegen (= einreichen; mit Sofort-Publish geht sie direkt live)
curl -s -i -X POST '{$host}/service/pressreleases' \\
-H 'X-ApiKey: {$apiKey}' \\
-d 'press_release[company_id]={$company->id}' \\
-d 'press_release[category_id]={$categoryId}' \\
-d 'press_release[language]=de' \\
-d 'press_release[title]=Testmeldung ueber die Legacy-API' \\
-d 'press_release[text]=Dies ist eine Testmeldung aus der Legacy-API-Kompatibilitaetsschicht.' \\
-d 'press_release[contacts_list][]={$contact->id}'
# 4. Bild hochladen (PM_ID aus Location-Header von Schritt 3)
curl -s -i -X POST '{$host}/service/pressreleases/PM_ID/images' \\
-H 'X-ApiKey: {$apiKey}' \\
-F 'press_release_image[image]=@pressefoto.jpg' \\
-F 'press_release_image[copyright]=API Test GmbH'
# 5. Veroeffentlichungs-Kontrolle (404 = noch nicht veroeffentlicht)
curl -s '{$host}/service/pressreleases/PM_ID' -H 'X-ApiKey: {$apiKey}'
CURL);
$this->newLine();
$this->comment('Hinweis: Schalter "KI-Pruefung Legacy-API" auf /admin/press-releases steuert Sofort-Publish vs. KI-Review.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use App\Mail\GoLivePasswordReset;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
/**
* Sendet Go-Live-Passwort-Reset-Mails an alle aktiven User.
*
* Verwendung:
* php artisan auth:send-go-live-mails --dry-run
* php artisan auth:send-go-live-mails --portal=presseecho
* php artisan auth:send-go-live-mails --limit=10
* php artisan auth:send-go-live-mails --force
*/
class SendGoLiveMails extends Command
{
protected $signature = 'auth:send-go-live-mails
{--dry-run : Nur Anzahl ausgeben, keine Mails senden}
{--portal= : Nur User eines Portals (presseecho|businessportal24|both)}
{--limit=0 : Maximale Anzahl (0 = alle)}
{--force : Bestätigung überspringen}';
protected $description = 'Sendet Go-Live-Passwort-Reset-Mails an alle aktiven Benutzer.';
private const EXPIRES_IN_MINUTES = 60;
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$portal = $this->option('portal');
$limit = (int) $this->option('limit');
$query = User::query()
->where('is_active', true)
->whereNotNull('email')
->when($portal, fn ($q) => $q->where('portal', $portal));
$total = $query->count();
$this->info("Aktive Benutzer gefunden: {$total}");
if ($portal) {
$this->line(" → Portal-Filter: {$portal}");
}
if ($limit > 0) {
$this->line(" → Limit: {$limit}");
$query->limit($limit);
$total = min($total, $limit);
}
if ($isDryRun) {
$this->warn("[DRY-RUN] Es würden {$total} Mails versendet. Kein tatsächlicher Versand.");
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Jetzt {$total} Go-Live-Mails senden?")) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
$sent = 0;
$failed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->each(function (User $user) use (&$sent, &$failed, $bar): void {
try {
$token = Password::broker()->createToken($user);
$resetUrl = url(route('password.reset', [
'token' => $token,
'email' => $user->email,
], false));
Mail::to($user->email)->send(
new GoLivePasswordReset($user, $resetUrl, self::EXPIRES_IN_MINUTES)
);
$sent++;
} catch (\Throwable $e) {
$failed++;
$this->newLine();
$this->error("Fehler für {$user->email}: {$e->getMessage()}");
}
$bar->advance();
});
$bar->finish();
$this->newLine(2);
$this->info("Versendet: {$sent}");
if ($failed > 0) {
$this->warn("Fehlgeschlagen: {$failed}");
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SyncCompanyLogos extends Command
{
protected $signature = 'legacy:sync-company-logos
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Ziel-Dateien erneut überschreiben}';
protected $description = 'Kopiert referenzierte Legacy-Firmenlogos in einen sauberen Storage-Pfad und aktualisiert companies.logo_path.';
public function handle(): int
{
$portals = $this->selectedPortals();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$totals = [
'referenced' => 0,
'copied' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
foreach ($portals as $portal) {
$stats = $this->syncPortal($portal, $dryRun, $force);
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
$portal,
$stats['referenced'],
$stats['copied'],
$stats['updated'],
$stats['already_synced'],
$stats['missing'],
$stats['unused'],
));
}
$this->newLine();
$this->info(sprintf(
'Gesamt: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
$totals['referenced'],
$totals['copied'],
$totals['updated'],
$totals['already_synced'],
$totals['missing'],
$totals['unused'],
$dryRun ? ' (Dry-Run)' : '',
));
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return array{referenced:int,copied:int,updated:int,already_synced:int,missing:int,unused:int}
*/
private function syncPortal(string $portal, bool $dryRun, bool $force): array
{
$sourceDirectory = "{$portal}/company";
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
$referencedFilenames = [];
$stats = [
'referenced' => 0,
'copied' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
Company::withoutGlobalScopes()
->where('legacy_portal', $portal)
->whereNotNull('logo_path')
->select(['id', 'legacy_portal', 'logo_path'])
->orderBy('id')
->chunkById(500, function ($companies) use ($allSourceFiles, $dryRun, $force, $portal, &$referencedFilenames, &$stats): void {
foreach ($companies as $company) {
$stats['referenced']++;
$sourceFilename = basename((string) $company->logo_path);
$referencedFilenames[$sourceFilename] = true;
if (Str::startsWith((string) $company->logo_path, 'company-logos/')) {
$stats['already_synced']++;
continue;
}
$sourcePath = $allSourceFiles->get($sourceFilename);
if (! $sourcePath) {
$stats['missing']++;
$this->warn("Fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
continue;
}
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
if (! $dryRun && ($force || ! Storage::disk('public')->exists($destinationPath))) {
Storage::disk('public')->copy($sourcePath, $destinationPath);
$stats['copied']++;
} elseif ($dryRun || Storage::disk('public')->exists($destinationPath)) {
$stats['copied'] += $dryRun ? 1 : 0;
}
if (! $dryRun) {
$company->forceFill([
'logo_path' => $destinationPath,
])->save();
}
$stats['updated']++;
}
});
$stats['unused'] = $allSourceFiles->keys()
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
->count();
return $stats;
}
}

View file

@ -0,0 +1,235 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SyncPressReleaseImages extends Command
{
protected $signature = 'legacy:sync-press-release-images
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Ziel-Dateien erneut überschreiben}
{--skip-variants : Varianten nicht regenerieren (nur Datei-Kopie + DB-Update)}
{--limit=0 : Maximal N Bilder pro Portal verarbeiten (0 = alle)}';
protected $description = 'Kopiert referenzierte Legacy-PM-Bilder in den finalen Storage-Pfad, generiert Varianten und aktualisiert press_release_images.';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$portals = $this->selectedPortals();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$skipVariants = (bool) $this->option('skip-variants');
$limit = max(0, (int) $this->option('limit'));
$totals = [
'referenced' => 0,
'copied' => 0,
'variants_generated' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
foreach ($portals as $portal) {
$stats = $this->syncPortal($portal, $dryRun, $force, $skipVariants, $limit);
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
$portal,
$stats['referenced'],
$stats['copied'],
$stats['variants_generated'],
$stats['updated'],
$stats['already_synced'],
$stats['missing'],
$stats['unused'],
));
}
$this->newLine();
$this->info(sprintf(
'Gesamt: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
$totals['referenced'],
$totals['copied'],
$totals['variants_generated'],
$totals['updated'],
$totals['already_synced'],
$totals['missing'],
$totals['unused'],
$dryRun ? ' (Dry-Run)' : '',
));
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return array{referenced:int,copied:int,variants_generated:int,updated:int,already_synced:int,missing:int,unused:int}
*/
private function syncPortal(string $portal, bool $dryRun, bool $force, bool $skipVariants, int $limit): array
{
$sourceDirectory = "{$portal}/press_release";
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
$referencedFilenames = [];
$stats = [
'referenced' => 0,
'copied' => 0,
'variants_generated' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
$query = PressReleaseImage::withoutGlobalScopes()
->where('legacy_portal', $portal)
->whereNotNull('path')
->select(['id', 'press_release_id', 'legacy_portal', 'path', 'variants'])
->orderBy('id');
$processed = 0;
$reachLimit = false;
$query->chunkById(500, function ($images) use (
$allSourceFiles, $dryRun, $force, $skipVariants, $portal, $limit,
&$referencedFilenames, &$stats, &$processed, &$reachLimit,
): bool {
foreach ($images as $image) {
if ($reachLimit) {
return false;
}
$stats['referenced']++;
$sourceFilename = basename((string) $image->path);
$referencedFilenames[$sourceFilename] = true;
if (Str::startsWith((string) $image->path, 'press-releases/')) {
$stats['already_synced']++;
if (! $skipVariants && (! is_array($image->variants) || $image->variants === [])) {
if (! $dryRun) {
$variants = $this->imageService->generateMissingPressReleaseVariants($image->path);
if ($variants !== []) {
$image->forceFill(['variants' => $variants])->save();
$stats['variants_generated']++;
$stats['updated']++;
}
} else {
$stats['variants_generated']++;
}
}
$processed++;
if ($limit > 0 && $processed >= $limit) {
$reachLimit = true;
return false;
}
continue;
}
$sourcePath = $allSourceFiles->get($sourceFilename);
if (! $sourcePath) {
$stats['missing']++;
$this->warn("Fehlt: {$portal}/press_release/{$sourceFilename} (Image #{$image->id})");
continue;
}
$destinationPath = "press-releases/{$image->press_release_id}/images/{$sourceFilename}";
if (! $dryRun) {
if ($force || ! Storage::disk('public')->exists($destinationPath)) {
Storage::disk('public')->copy($sourcePath, $destinationPath);
$stats['copied']++;
}
} else {
$stats['copied']++;
}
$variants = [];
if (! $skipVariants && ! $dryRun) {
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
if ($variants !== []) {
$stats['variants_generated']++;
}
} elseif (! $skipVariants && $dryRun) {
$stats['variants_generated']++;
}
if (! $dryRun) {
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
$image->forceFill([
'disk' => 'public',
'path' => $destinationPath,
'variants' => $variants !== [] ? $variants : $image->variants,
'width' => is_int($size[0] ?? null) ? $size[0] : $image->width,
'height' => is_int($size[1] ?? null) ? $size[1] : $image->height,
])->save();
}
$stats['updated']++;
$processed++;
if ($limit > 0 && $processed >= $limit) {
$reachLimit = true;
return false;
}
}
return true;
});
$stats['unused'] = $allSourceFiles->keys()
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
->count();
return $stats;
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace App\Console\Commands;
use App\Models\Plan;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
/**
* Legt die Tarife aus `plans` als Produkte/Preise in Stripe an und pflegt
* die Stripe-IDs zurück (Phase 9E).
*
* Preise sind NETTO (`tax_behavior: exclusive`) — die Steuer wird im
* Checkout ergänzt (Entscheidung 12.06.2026). Idempotent: vorhandene
* IDs werden übersprungen; Preisänderungen erzeugen in Stripe bewusst
* neue Price-Objekte (Stripe-Preise sind unveränderlich), der alte Preis
* bleibt für Bestandsabos gültig.
*/
class SyncStripePlans extends Command
{
protected $signature = 'billing:sync-stripe-plans
{--dry-run : Nur anzeigen, was angelegt würde}';
protected $description = 'Synchronisiert die Tarife aus plans als Netto-Produkte/Preise nach Stripe';
public function handle(): int
{
if (! config('cashier.secret')) {
$this->error('STRIPE_SECRET ist nicht gesetzt.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$stripe = Cashier::stripe();
foreach (Plan::query()->active()->get() as $plan) {
if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) {
// Preise sind unveränderlich, der Produktname nicht: Er wird
// bei jedem Lauf nachgezogen, damit Stripe-Belege den
// sprechenden Namen tragen (statt z. B. nur "Starter").
if ($dryRun) {
$this->line("[dry-run] {$plan->slug}: verknüpft — Produktname auf \"{$this->stripeProductName($plan)}\" aktualisieren.");
continue;
}
$stripe->products->update($plan->stripe_product_id, [
'name' => $this->stripeProductName($plan),
]);
$this->line("{$plan->slug}: verknüpft — Produktname aktualisiert.");
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry-run] %s: Produkt + Preise %s €/Monat, %s €/Jahr (netto) anlegen.',
$plan->slug,
number_format($plan->monthly_price_cents / 100, 2, ',', '.'),
number_format($plan->yearly_price_cents / 100, 2, ',', '.'),
));
continue;
}
$productId = $plan->stripe_product_id;
if (! $productId) {
$product = $stripe->products->create([
'name' => $this->stripeProductName($plan),
'metadata' => ['plan_slug' => $plan->slug],
]);
$productId = $product->id;
}
$monthlyId = $plan->stripe_price_id_monthly
?: $this->createPrice($stripe, $productId, $plan, $plan->monthly_price_cents, 'month');
$yearlyId = $plan->stripe_price_id_yearly
?: $this->createPrice($stripe, $productId, $plan, $plan->yearly_price_cents, 'year');
$plan->update([
'stripe_product_id' => $productId,
'stripe_price_id_monthly' => $monthlyId,
'stripe_price_id_yearly' => $yearlyId,
]);
$this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}");
}
$this->syncSinglePmPrice($stripe, $dryRun);
return self::SUCCESS;
}
/**
* Legt das Einmal-Produkt „Einzel-Pressemitteilung" an (Netto-Preis aus
* billing.single_pm_price_cents). Die Price-ID landet bewusst in der ENV
* (STRIPE_PRICE_SINGLE_PM) statt in einer Tabelle — es gibt genau einen
* solchen Preis, und ohne ENV bleibt der Checkout deaktiviert.
*/
private function syncSinglePmPrice(object $stripe, bool $dryRun): void
{
if (config('billing.single_pm_stripe_price_id')) {
$this->line('einzel-pm: bereits verknüpft (STRIPE_PRICE_SINGLE_PM), übersprungen.');
return;
}
$amount = (int) config('billing.single_pm_price_cents');
if ($dryRun) {
$this->line(sprintf(
'[dry-run] einzel-pm: Einmal-Produkt + Preis %s € (netto) anlegen.',
number_format($amount / 100, 2, ',', '.'),
));
return;
}
$product = $stripe->products->create([
'name' => 'Einzel-Pressemitteilung',
'metadata' => ['purpose' => 'single_pm'],
]);
$price = $stripe->prices->create([
'product' => $product->id,
'currency' => strtolower((string) config('billing.default_currency')),
'unit_amount' => $amount,
'tax_behavior' => 'exclusive',
'metadata' => ['purpose' => 'single_pm'],
]);
$this->info("einzel-pm: {$product->id} · {$price->id}");
$this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}");
}
/**
* Sprechender Produktname für Stripe-Belege: Auf Rechnung und
* Checkout-Seite erscheint sonst nur der nackte Tarifname.
*/
private function stripeProductName(Plan $plan): string
{
return sprintf(
'Tarif %s — %d Pressemitteilungen/Monat (pressekonto)',
$plan->name,
$plan->press_release_quota,
);
}
private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string
{
$price = $stripe->prices->create([
'product' => $productId,
'currency' => strtolower($plan->currency),
'unit_amount' => $unitAmount,
'tax_behavior' => 'exclusive',
'recurring' => ['interval' => $interval],
'metadata' => ['plan_slug' => $plan->slug],
]);
return $price->id;
}
}

View file

@ -0,0 +1,408 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class VerifyLegacyImport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'legacy:verify
{--portal=all : Portal (presseecho|businessportal24|all)}
{--skip-legacy : Legacy-DB-Checks überspringen}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verifiziert den Legacy-Import und schreibt einen maschinenlesbaren Report.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
private const ENTITY_MAP = [
'users' => [
'legacy_table' => 'sf_guard_user',
'target_table' => 'users',
'target_column' => 'legacy_id',
],
'companies' => [
'legacy_table' => 'company',
'target_table' => 'companies',
'target_column' => 'legacy_id',
],
'contacts' => [
'legacy_table' => 'contact',
'target_table' => 'contacts',
'target_column' => 'legacy_id',
],
'categories' => [
'legacy_table' => 'category',
'target_table' => 'categories',
'target_column' => 'legacy_id',
],
'press_releases' => [
'legacy_table' => 'press_release',
'target_table' => 'press_releases',
'target_column' => 'legacy_id',
],
];
/**
* Execute the console command.
*/
public function handle(): int
{
$portalOption = (string) $this->option('portal');
$skipLegacy = (bool) $this->option('skip-legacy');
$portals = $portalOption === 'all'
? array_keys(self::PORTAL_CONNECTIONS)
: [$portalOption];
if (array_diff($portals, array_keys(self::PORTAL_CONNECTIONS)) !== []) {
$this->error("Unbekanntes Portal: {$portalOption}");
return self::FAILURE;
}
$report = [
'generated_at' => now()->toIso8601String(),
'portal' => $portalOption,
'skip_legacy' => $skipLegacy,
'checks' => [],
'summary' => [
'failures' => 0,
'warnings' => 0,
],
];
$this->info('Legacy-Import-Verifikation');
$this->newLine();
if (! $skipLegacy) {
$this->verifyLegacyCounts($report, $portals);
$this->verifyLegacyInvoices($report, $portals);
} else {
$this->warn('Legacy-DB-Checks werden übersprungen.');
}
$this->verifyImportMapTargets($report, $portals);
$this->verifyTargetForeignKeys($report);
$this->verifyUserMerges($report);
$this->verifyGrandfatheredSubscriptions($report);
$this->renderSummary($report);
if (! (bool) $this->option('no-report')) {
$path = 'migration/verify-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return $report['summary']['failures'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyLegacyCounts(array &$report, array $portals): void
{
$rows = [];
foreach ($portals as $portal) {
foreach (self::ENTITY_MAP as $entity => $config) {
$legacyCount = $this->legacyCount($portal, $config['legacy_table']);
$targetCount = DB::table($config['target_table'])
->where('legacy_portal', $portal)
->whereNotNull($config['target_column'])
->count();
$mappedCount = DB::table('legacy_import_map')
->where('legacy_portal', $portal)
->where('legacy_table', $config['legacy_table'])
->where('target_table', $config['target_table'])
->count();
$rows[] = [
'portal' => $portal,
'entity' => $entity,
'legacy_count' => $legacyCount,
'target_count' => $targetCount,
'mapped_count' => $mappedCount,
'delta' => $legacyCount - $targetCount,
];
}
}
$this->addCheck($report, 'legacy_counts', 'ok', 'Legacy- und Ziel-Counts erfasst.', $rows);
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyLegacyInvoices(array &$report, array $portals): void
{
$rows = [];
$failures = 0;
foreach ($portals as $portal) {
$legacy = DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('invoice')
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
->first();
$target = DB::table('legacy_invoices')
->where('legacy_portal', $portal)
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount_cents), 0) as amount_cents')
->first();
$legacyAmountCents = (int) round((float) $legacy->amount * 100);
$targetAmountCents = (int) $target->amount_cents;
$countDelta = (int) $legacy->count - (int) $target->count;
$amountDelta = $legacyAmountCents - $targetAmountCents;
if ($countDelta !== 0 || $amountDelta !== 0) {
$failures++;
}
$rows[] = [
'portal' => $portal,
'legacy_count' => (int) $legacy->count,
'target_count' => (int) $target->count,
'count_delta' => $countDelta,
'legacy_amount_cents' => $legacyAmountCents,
'target_amount_cents' => $targetAmountCents,
'amount_delta_cents' => $amountDelta,
];
}
$this->addCheck(
$report,
'legacy_invoices',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Legacy-Rechnungen stimmen mit dem Archiv überein.' : 'Legacy-Rechnungsarchiv weicht von der Quelle ab.',
$rows,
);
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyImportMapTargets(array &$report, array $portals): void
{
$rows = [];
$failures = 0;
foreach (self::ENTITY_MAP as $entity => $config) {
foreach ($portals as $portal) {
$missingTargets = DB::table('legacy_import_map as map')
->leftJoin($config['target_table'].' as target', 'target.id', '=', 'map.target_id')
->where('map.legacy_portal', $portal)
->where('map.legacy_table', $config['legacy_table'])
->where('map.target_table', $config['target_table'])
->whereNull('target.id')
->count();
if ($missingTargets > 0) {
$failures++;
}
$rows[] = [
'portal' => $portal,
'entity' => $entity,
'missing_targets' => $missingTargets,
];
}
}
$this->addCheck(
$report,
'import_map_targets',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Alle Import-Map-Ziele existieren.' : 'Import-Map enthält verwaiste Ziel-IDs.',
$rows,
);
}
/**
* @param array<string, mixed> $report
*/
private function verifyTargetForeignKeys(array &$report): void
{
$checks = [
'companies.owner_user_id' => DB::table('companies as c')
->leftJoin('users as u', 'u.id', '=', 'c.owner_user_id')
->whereNotNull('c.owner_user_id')
->whereNull('u.id'),
'contacts.company_id' => DB::table('contacts as c')
->leftJoin('companies as co', 'co.id', '=', 'c.company_id')
->whereNull('co.id'),
'press_releases.user_id' => DB::table('press_releases as pr')
->leftJoin('users as u', 'u.id', '=', 'pr.user_id')
->whereNull('u.id'),
'press_releases.company_id' => DB::table('press_releases as pr')
->leftJoin('companies as c', 'c.id', '=', 'pr.company_id')
->whereNotNull('pr.company_id')
->whereNull('c.id'),
'press_releases.category_id' => DB::table('press_releases as pr')
->leftJoin('categories as c', 'c.id', '=', 'pr.category_id')
->whereNull('c.id'),
'company_user.company_id' => DB::table('company_user as pivot')
->leftJoin('companies as c', 'c.id', '=', 'pivot.company_id')
->whereNull('c.id'),
'company_user.user_id' => DB::table('company_user as pivot')
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
->whereNull('u.id'),
'contact_user.contact_id' => DB::table('contact_user as pivot')
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
->whereNull('c.id'),
'contact_user.user_id' => DB::table('contact_user as pivot')
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
->whereNull('u.id'),
'press_release_contact.press_release_id' => DB::table('press_release_contact as pivot')
->leftJoin('press_releases as pr', 'pr.id', '=', 'pivot.press_release_id')
->whereNull('pr.id'),
'press_release_contact.contact_id' => DB::table('press_release_contact as pivot')
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
->whereNull('c.id'),
'legacy_invoices.user_id' => DB::table('legacy_invoices as li')
->leftJoin('users as u', 'u.id', '=', 'li.user_id')
->whereNotNull('li.user_id')
->whereNull('u.id'),
];
$rows = [];
$failures = 0;
foreach ($checks as $name => $query) {
$count = $query->count();
if ($count > 0) {
$failures++;
}
$rows[] = [
'relation' => $name,
'dangling_count' => $count,
];
}
$this->addCheck(
$report,
'target_foreign_keys',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Keine verwaisten Ziel-Foreign-Keys gefunden.' : 'Zieltabellen enthalten verwaiste Foreign Keys.',
$rows,
);
}
/**
* @param array<string, mixed> $report
*/
private function verifyUserMerges(array &$report): void
{
$bothUsers = DB::table('users')
->where('portal', Portal::Both->value)
->count();
$importMapsForBothUsers = DB::table('legacy_import_map as map')
->join('users as users', 'users.id', '=', 'map.target_id')
->where('map.legacy_table', 'sf_guard_user')
->where('map.target_table', 'users')
->where('users.portal', Portal::Both->value)
->count();
$this->addCheck($report, 'user_merges', 'ok', 'Portalübergreifende User-Merges erfasst.', [
'users_with_portal_both' => $bothUsers,
'legacy_maps_to_portal_both_users' => $importMapsForBothUsers,
]);
}
/**
* @param array<string, mixed> $report
*/
private function verifyGrandfatheredSubscriptions(array &$report): void
{
$rows = DB::table('user_payment_options')
->selectRaw('status, COUNT(*) as count, MIN(grandfathered_until) as earliest_grandfathered_until, MAX(grandfathered_until) as latest_grandfathered_until')
->where('status', 'grandfathered')
->groupBy('status')
->get()
->map(fn (object $row): array => [
'status' => $row->status,
'count' => (int) $row->count,
'earliest_grandfathered_until' => $row->earliest_grandfathered_until,
'latest_grandfathered_until' => $row->latest_grandfathered_until,
])
->all();
$this->addCheck($report, 'grandfathered_subscriptions', 'ok', 'Grandfathered Subscriptions erfasst.', $rows);
}
private function legacyCount(string $portal, string $table): int
{
return DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table($table)
->count();
}
/**
* @param array<string, mixed> $report
* @param array<mixed> $details
*/
private function addCheck(array &$report, string $name, string $status, string $message, array $details): void
{
$report['checks'][$name] = [
'status' => $status,
'message' => $message,
'details' => $details,
];
if ($status === 'fail') {
$report['summary']['failures']++;
}
if ($status === 'warning') {
$report['summary']['warnings']++;
}
}
/**
* @param array<string, mixed> $report
*/
private function renderSummary(array $report): void
{
foreach ($report['checks'] as $name => $check) {
$status = match ($check['status']) {
'ok' => '<info>OK</info>',
'warning' => '<comment>WARN</comment>',
default => '<error>FAIL</error>',
};
$this->line("{$status} {$name}: {$check['message']}");
}
$this->newLine();
$this->table(
['Status', 'Anzahl'],
[
['Fehler', $report['summary']['failures']],
['Warnungen', $report['summary']['warnings']],
],
);
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
\App\Console\Commands\GenerateDomainFavicons::class,
];
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Contracts;
use App\Models\NewsletterSubscription;
interface NewsletterSyncClient
{
public function subscribe(NewsletterSubscription $subscription): void;
public function unsubscribe(NewsletterSubscription $subscription): void;
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Enums;
/**
* Ereignistypen des Aktivitäts-Verlaufs (Admin ▸ Reports ▸ Verlauf).
* Jeder Typ entspricht einem fachlichen Systemereignis; Details stecken
* in description/meta des ActivityEvent.
*/
enum ActivityType: string
{
case UserRegistered = 'user_registered';
case UserLoggedIn = 'user_logged_in';
case PressReleaseStatus = 'press_release_status';
case BookingPaid = 'booking_paid';
case CreditTopup = 'credit_topup';
case ContactRequest = 'contact_request';
case LegalRequest = 'legal_request';
case WithdrawalRequest = 'withdrawal_request';
case TerminationRequest = 'termination_request';
case NewsletterSubscription = 'newsletter_subscription';
case SubscriptionChange = 'subscription_change';
public function label(): string
{
return match ($this) {
self::UserRegistered => 'Registrierung',
self::UserLoggedIn => 'Anmeldung',
self::PressReleaseStatus => 'Pressemitteilung',
self::BookingPaid => 'Zahlung',
self::CreditTopup => 'Credit-Aufladung',
self::ContactRequest => 'Kontaktanfrage',
self::LegalRequest => 'Rechtsanfrage',
self::WithdrawalRequest => 'Widerruf',
self::TerminationRequest => 'Kündigung',
self::NewsletterSubscription => 'Newsletter',
self::SubscriptionChange => 'Abo',
};
}
/**
* Flux-Badge-Farbe in der Verlauf-Tabelle.
*/
public function badgeColor(): string
{
return match ($this) {
self::UserRegistered => 'green',
self::UserLoggedIn => 'zinc',
self::PressReleaseStatus => 'blue',
self::BookingPaid, self::CreditTopup => 'amber',
self::ContactRequest, self::NewsletterSubscription => 'cyan',
self::LegalRequest, self::WithdrawalRequest, self::TerminationRequest, self::SubscriptionChange => 'red',
};
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes je PM-Bild (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
*/
enum AiImageDeclaration: string
{
case Original = 'original';
case AiGenerated = 'ai_generated';
case AiEdited = 'ai_edited';
/**
* Wortlaut der Auswahloption im Upload-Formular.
*/
public function label(): string
{
return match ($this) {
self::Original => __('Originalfoto'),
self::AiGenerated => __('KI-generiertes Bild'),
self::AiEdited => __('KI-bearbeitetes Bild'),
};
}
/**
* Öffentlicher Bildhinweis direkt am Bild (Badge) — null bei Originalfotos
* (keine Nicht-KI-Zertifizierung in Portalstimme). Kurzform „KI-generiert"
* statt Satz-Wortlaut (User-Entscheidung 10.07.2026).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::Original => null,
self::AiGenerated => __('KI-generiert'),
self::AiEdited => __('KI-bearbeitet'),
};
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Enums;
/**
* Selbst-Deklaration des KI-Einsatzes beim PM-Text (Art. 50 Abs. 4 KI-VO).
*
* Pflichtauswahl im Submit-Flow (Decision-Update KI-Kennzeichnung, 08.07.2026;
* vereinfacht auf zwei Auswahloptionen, 10.07.2026): gelabelt wird allein nach
* dem deklarierten KI-Grad, nicht nach der Public-Interest-Frage — bewusste
* Über-Erfüllung. Das öffentliche Label spricht in Einreicher-Stimme, nie in
* Portalstimme.
*
* Alle vier Werte bleiben gültig (API-Vertrag v1 + Bestandsdaten); UI und
* Label-Logik fassen sie zu zwei Gruppen zusammen: None/Assisted = kein Label,
* MostlyAi/FullyAi = KI-Label.
*/
enum AiTextDeclaration: string
{
case None = 'none';
case Assisted = 'assisted';
case MostlyAi = 'mostly_ai';
case FullyAi = 'fully_ai';
/**
* Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen).
*/
public function label(): string
{
return match ($this) {
self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'),
self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'),
};
}
/**
* Öffentliches Label in Einreicher-Stimme — null, wenn kein Pflicht-Label
* fällig ist ("Ohne KI" wird bewusst nicht als Portal-Aussage zertifiziert,
* die Formulierungshilfe entspricht dem Entlastungsfall).
*/
public function publicLabel(): ?string
{
return match ($this) {
self::None, self::Assisted => null,
self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'),
};
}
/**
* Kanonische Formular-Option der Gruppe — mappt Alt-/API-Werte
* (assisted, fully_ai) auf die zwei im Modal angebotenen Radios.
*/
public function formOption(): self
{
return match ($this) {
self::None, self::Assisted => self::None,
self::MostlyAi, self::FullyAi => self::MostlyAi,
};
}
/**
* Die zwei im Submit-Modal angebotenen Auswahloptionen.
*
* @return list<self>
*/
public static function formCases(): array
{
return [self::None, self::MostlyAi];
}
/**
* Optionsliste für Radios/Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
/**
* Platzierungs-Stufe eines Boosts (Entscheidung 13.07.2026): der
* Startseiten-Boost (voller Preis) erscheint auf Startseite UND Branchenseite,
* der Kategorie-Boost (halber Preis) nur auf der Branchenseite. Beide Stufen
* haben ein eigenes Slot-Inventar je Ausgabe (config credits.boost_slots).
*/
enum BoostPlacement: string
{
case Home = 'home';
case Category = 'category';
public function label(): string
{
return match ($this) {
self::Home => 'Startseiten-Boost',
self::Category => 'Kategorie-Boost',
};
}
/**
* Wo die Platzierung sichtbar ist (für Add-on-UI und Kauf-Mails).
*/
public function placementDescription(): string
{
return match ($this) {
self::Home => 'Startseite und Branchenseite',
self::Category => 'Branchenseite der Rubrik',
};
}
}

17
app/Enums/CompanyType.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum CompanyType: string
{
case Company = 'company';
case Agency = 'agency';
public function label(): string
{
return match ($this) {
self::Company => 'Unternehmen',
self::Agency => 'Agentur',
};
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Enums;
enum ContactMessageStatus: string
{
case Open = 'open';
case InProgress = 'in_progress';
case Resolved = 'resolved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Open => 'Offen',
self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt',
};
}
/**
* Offene Anliegen, die in der Kontakt-Queue Aufmerksamkeit brauchen
* (Nav-Zähler und Default-Filter der Admin-Ansicht).
*/
public function isOpen(): bool
{
return $this === self::Open || $this === self::InProgress;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum ContactMessageTopic: string
{
case Contact = 'contact';
case Advertising = 'advertising';
case BugReport = 'bug_report';
case PressReleaseReport = 'press_release_report';
case Feedback = 'feedback';
/**
* Interne (Admin-)Bezeichnung.
*/
public function label(): string
{
return match ($this) {
self::Contact => 'Kontaktaufnahme',
self::Advertising => 'Werbeanfrage',
self::BugReport => 'Fehlermeldung',
self::PressReleaseReport => 'PM-Meldung',
self::Feedback => 'Feedback',
};
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
/**
* Buchungsart im Wallet-Ledger. `Topup`/`Grant` schreiben gut, `Spend`
* belastet, `Refund` erstattet eine vorherige Belastung.
*/
enum CreditTransactionType: string
{
case Topup = 'topup';
case Spend = 'spend';
case Refund = 'refund';
case Grant = 'grant';
public function label(): string
{
return match ($this) {
self::Topup => 'Aufladung',
self::Spend => 'Verbrauch',
self::Refund => 'Erstattung',
self::Grant => 'Gutschrift',
};
}
/**
* Schreibt die Buchung dem Saldo gut (statt ihn zu belasten)?
*/
public function isCredit(): bool
{
return in_array($this, [self::Topup, self::Refund, self::Grant], true);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Enums;
/**
* Abbestellbare Benachrichtigungs-Mails (Opt-out je Typ).
*
* Umfasst bewusst nur Benachrichtigungen zum PM-Lebenszyklus — keine
* System-/Transaktionsmails (Passwort-Reset, Magic-Link, Kündigungs-/
* Widerrufsbestätigungen), die rechtlich bzw. funktional zwingend sind.
* Jeder Case entspricht genau einer Mailable-Klasse; vor dem Versand wird
* geprüft, ob der Empfänger den Typ abbestellt hat
* (User::wantsEmailNotification()).
*/
enum EmailNotificationType: string
{
case PressReleasePublished = 'press_release_published';
case PressReleaseRejected = 'press_release_rejected';
case PressReleaseDepublished = 'press_release_depublished';
public function label(): string
{
return match ($this) {
self::PressReleasePublished => 'Pressemitteilung veröffentlicht',
self::PressReleaseRejected => 'Pressemitteilung abgelehnt',
self::PressReleaseDepublished => 'Pressemitteilung depubliziert',
};
}
/**
* Beschreibung für die Präferenzen-Einstellung im Kundenbereich.
*/
public function settingsDescription(): string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.',
self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
};
}
/**
* „Warum erhalten Sie diese E-Mail?"-Begründung im Mail-Footer.
*/
public function mailReason(): string
{
return match ($this) {
self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
};
}
/**
* Was der Empfänger mit dem Abmelde-Link abbestellt (Mail-Footer).
*/
public function unsubscribeLabel(): string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen',
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
};
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Enums;
/**
* Lizenz-/Herkunftstyp eines Pressemitteilungs-Bildes.
*
* Dient der rechtssicheren Erfassung von Bildrechten beim Upload.
*/
enum ImageLicenseType: string
{
case Own = 'own';
case CreativeCommons = 'cc';
case Commercial = 'commercial';
case Consent = 'consent';
case PressPr = 'press_pr';
case PublicDomain = 'public_domain';
case AiGenerated = 'ai_generated';
case Other = 'other';
/**
* Lesbares Label für die UI.
*/
public function label(): string
{
return match ($this) {
self::Own => 'Eigene Aufnahme',
self::Consent => 'Vom Urheber / Fotografen freigegeben',
self::Commercial => 'Agentur-/Stockbild-Lizenz',
self::CreativeCommons => 'Creative-Commons-Lizenz',
self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe',
self::PublicDomain => 'Gemeinfrei / Public Domain / CC0',
self::AiGenerated => 'KI-generiert (z. B. Midjourney, DALL·E, Firefly)',
self::Other => 'Sonstige Lizenz / Sondervereinbarung',
};
}
/**
* Ob für diesen Typ eine Lizenz-URL Pflicht ist.
*/
public function requiresLicenseUrl(): bool
{
return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true);
}
/**
* Ob zusaetzliche Lizenzdetails verpflichtend sind.
* Bei KI-Bildern ist das Detail das verwendete Tool (AI-Act-Kennzeichnung).
*/
public function requiresLicenseDetail(): bool
{
return in_array($this, [self::CreativeCommons, self::AiGenerated, self::Other], true);
}
/**
* Rein KI-generierte Bilder haben keinen menschlichen Urheber (§ 2 UrhG);
* maßgeblich sind die Anbieter-Bedingungen und die Kennzeichnungspflicht
* aus Art. 50 EU AI Act (ab 02.08.2026).
*/
public function isAiGenerated(): bool
{
return $this === self::AiGenerated;
}
/**
* Optionsliste für Selects: value => label.
*
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum InvoiceStatus: string
{
case Open = 'open';
case Paid = 'paid';
case Void = 'void';
case Uncollectible = 'uncollectible';
public function label(): string
{
return match ($this) {
self::Open => 'Offen',
self::Paid => 'Bezahlt',
self::Void => 'Storniert',
self::Uncollectible => 'Uneinbringlich',
};
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Enums;
enum LegalRequestStatus: string
{
case Open = 'open';
case InProgress = 'in_progress';
case Resolved = 'resolved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Open => 'Offen',
self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt',
};
}
/**
* Offene Anfragen, die in der Queue Aufmerksamkeit brauchen (für die Bremse
* „1 offene Anfrage pro PM/Typ" und den Nav-Zähler).
*/
public function isOpen(): bool
{
return $this === self::Open || $this === self::InProgress;
}
}

Some files were not shown because too many files have changed in this diff Show more