Commit graph

64 commits

Author SHA1 Message Date
fc700677ff web: Abruf-Protokoll der RSS-Feeds — Reader-Dienst, gemeldete Abonnenten, Admin-Bericht, Panel (Roadmap 3.1)
- feed_fetches: jeder Feed-Abruf mit Art, Schlüssel, Portal, Sprache,
  erkanntem Reader (config/feeds.php, FeedReaderIdentifier), vom Dienst
  gemeldeter Abonnentenzahl und gekürztem User-Agent — keine IP. Schreiben
  per defer() nach der Antwort, auch bei Cache-Treffer.
- Aufbewahrung 395 Tage über press-releases:purge-events;
  FEED_FETCHES_RECORDED_SINCE.
- Admin ▸ Reports ▸ Indexierung & Abrufe: Block „Abrufe der RSS-Feeds"
  (Summen je Feed-Art, je Reader-Dienst, meistabgerufene Feeds;
  FeedFetchReport, Verdichtung in PHP für MySQL und SQLite).
- Panel „Auslieferung": Abrufe je Feed in 30 Tagen; RSS-Seite nennt die
  Zählung ohne IP.
- Tests: FeedFetchLogTest (5), IndexStatusCheckTest erweitert.
- dev/2026-09-01: Nachtrag Protokoll Teil 11, Roadmap 3.1; 0.7 gilt auch
  für Crawler- und Feed-Log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:00:09 +00:00
ff75159b8f web: RSS-Feed je Newsroom, RSS-Übersichtsseite, „RSS" in der Topleiste (Roadmap 3.1, Nachtrag)
- /{edition}/newsroom/{slug}/feed: Sichtbarkeit wie die Newsroom-Seite
  (aktiv, nicht entfernt, mindestens eine Meldung), Prüfung vor dem Cache,
  301 bei zusammengeführter Firma; Autodiscovery und Link „RSS-Feed" auf
  der Newsroom-Seite; Hub leitet auf das Portal der Firma.
- /{edition}/rss (RssPageController, static.rss DE/EN): Was RSS ist, wie
  ein Reader funktioniert, Hauptfeed, alle Branchen-Feeds mit Adresse,
  Muster des Newsroom-Feeds, Hinweise. Portalseite mit Portal-Kontext,
  Hub → presseecho, in Sitemap und llms.txt.
- Topleiste, Burger-Menü und Footer verlinken RSS neben FAQ/Hilfe/API.
- FeedController in portal/category/newsroom aufgeteilt; Panel
  „Auslieferung" nennt zusätzlich den Newsroom-Feed.
- Tests: FeedTest (+3), RssPageTest (4), StaticPagesTest, VisibilityStatsTest.
- dev/2026-09-01: Nachtrag in Protokoll Teil 11 und Roadmap 3.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 08:27:14 +00:00
9d5e5a0b54 web: RSS-Feeds je Branche und je Portal (Roadmap 3.1); dev: Sichtung des Ordners, Protokoll-Teile umnummeriert
- /{edition}/feed und /{edition}/category/{slug}/feed (FeedController,
  RSS 2.0 mit atom:link self, dc:creator, category; jüngste 50 der Ausgabe,
  Branche inkl. aktiver Unterkategorien, nur visibleInArchive).
- Cache über WebPageCache (Key-Gruppe feed), Hub leitet per 301 aufs Portal
  (canonical.portal_only_content_routes).
- Autodiscovery über @yield('feed_links') auf Start-, Branchen- und
  Archivseiten, sichtbarer Link in der Archiv-Leiste, llms.txt nennt die
  Feed-Muster, Block „RSS-Feeds" im Panel „Auslieferung"
  (VisibilityStats::feedLinks).
- Übersetzungen unter web.rss (web.feed ist die Startseiten-Liste).
- Tests: FeedTest (7), VisibilityStatsTest erweitert.
- dev/2026-09-01: Sichtung 18.09. (Teil 11), zweite Teil-Serie des
  Protokolls jetzt 7–10 mit nachgezogenen Verweisen, Anhang 1 abgeglichen,
  E6 abgehakt, 2.6 extern, 1c.5/E8 wartet auf Daten, 3.1 abgehakt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:57:09 +00:00
e2ef4dee31 admin: Search-Console-Vorgabe für businessportal24 auf die .com-Property
Die Vorgabe in config/search_console.php nannte sc-domain:businessportal24.de;
die bestätigte Property ist businessportal24.com. Live ist der Wert per .env
gesetzt, die Vorgabe griff nur ohne Eintrag. .env.example bereits in 2ec6169.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:57:08 +00:00
85d7c2b0d5 betrieb: Logs je Thema in Tagesdateien (billing, ki, monitoring, market); Doku: Duplicate-Content-Decision durch Anhang 2 abgelöst
- config/logging.php: Themen-Channels billing, ki, monitoring, market als
  daily-Dateien mit gemeinsamer Aufbewahrung (LOG_DAILY_DAYS), Stack-Default
  auf daily; Channel `testing` für die Suite.
- Fachliche Log-Aufrufe in Billing, KI-Jobs, OperationalAlert und
  Kursdaten auf Log::channel() umgestellt.
- phpunit.xml + TestCase: Suite schreibt ausschließlich nach
  testing-YYYY-MM-DD.log; LogChannelsTest sichert die Konfiguration.
- docs/weiteres: Decision-Update Duplicate-Content entfernt (überholt durch
  Roadmap-Anhang 2), Decision-Update Verlinkung & Backlinks als Referenz
  wieder abgelegt, README-Index nachgezogen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:35:42 +00:00
05cf53e17d security/api/betrieb: Fortify-2FA-Nebenwege gesperrt, Policy nur für aktive Super-Admins, Nachprüfungen aus dem Umsetzungsaudit (Block 7, U08–U10, U13, 7.1, 7.2, 7.5)
- Fortifys sieben 2FA-Verwaltungsrouten antworten mit 404 (Middleware über
  fortify.middleware); die Sicherheitsseite verlangt weiter den zweiten Faktor (U09)
- PressReleasePolicy/CompanyPolicy::before() bejahen nur aktive Super-Admins (7.2)
- API-Update prüft bei Portalwechsel die bestehende Kategorie gegen das neue Portal (U10)
- Aggregation: Zeitbudget 480 s unter dem Lock von 600 s, Marke nur unter
  Zeilensperre vom erwarteten Stand aus, sonst Fehler-Exit (U13)
- Newsletter-Bereinigungsmigration behält je Paar die sent-Zeile (U08)
- E-Mail-Wechsel stößt den Rechte-Sync sofort an; company-memberships:audit
  --classify zeigt vorher die Kandidaten und begrenzt mit --before (7.1)
- Detailseite wählt bei Slug-Kollision wie der Melde-Resolver (7.5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:03:04 +00:00
72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00
1d76f5a733 auth: Firmenrechte nur aus nachgewiesener Adresse, 2FA auf allen Login-Wegen (Audit Block 1, Deploy A)
Audit vom 11.09. gegen HEAD geprüft, Entwicklungsplan dazu unter
dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md. Dieser Commit ist Deploy A:
die sechs kleinen Punkte aus Block 1 ohne Schemaänderung.

F01: Der Fortify-Endpunkt PUT /user/profile-information tauschte die
E-Mail sofort und setzte die Verifizierung zurück; der Login-Sync vergab
danach Firmenrechte allein nach E-Mail-Treffer. Feature abgeschaltet
(die Kunden-UI hat den pending_email-Prozess), und der Sync verlangt
jetzt ein verifiziertes, aktives Konto – für alle Aufrufer.

F02: Google-Login übergibt Konten mit bestätigtem TOTP in die Challenge,
wie Passwort- und Magic-Link-Login.

F03: showRecoveryCodes ist gesperrt; ein Client-Update konnte die
Recovery-Codes entschlüsseln lassen.

F08: Nutzer-Detailroute unter can:users:manage (persistente Livewire-
Middleware), Gutschriften autorisieren zusätzlich in der Action.

F09: Gate::before greift nur für aktive Super-Admins; deaktivierte
fallen auf die Gates mit is_active-Prüfung zurück.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:08:55 +00:00
edb2d49199 web: Portal-Inhalte sind auf dem Hub nicht mehr erreichbar (Anhang 2, E10)
Kategorien, Jahresarchive, Meldungen und Newsrooms liefen auch unter
pressekonto.com — die Ausgabe-Routen gelten für alle Hosts, und der
Portal-Kontext fällt dort auf businessportal24 zurück. Jetzt 301 auf das
Portal.

Meldung und Newsroom folgen ihrem eigenen Portal, nicht einer festen
Vorgabe; bei Doppelveröffentlichung (gleicher Slug auf beiden Portalen)
gewinnt die Erstquelle, sonst endete die Weiterleitung auf der Kopie.
Kategorie, Archiv und Suche gibt es auf beiden Portalen — dort entscheidet
canonical.portal_only_host.

Bewusst nicht weitergeleitet wird ein unbekannter Slug (sonst 301 in ein
404) und alles, was kein GET ist (ein 301 auf POST verliert die Anfrage —
Newsroom beanspruchen und Share-Zählung laufen weiter). Query-Parameter
bleiben erhalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:15:04 +00:00
5dcaf4e7b0 web: E9 und E10 entschieden — Zuordnung aktiv, eine Adresse je statischer Seite, Hub-Portalseiten leiten weiter
E9: Kategorie-Zuordnung wie vorgeschlagen übernommen, Zuordnung ist
standardmäßig an (CANONICAL_ASSIGNMENT_ENABLED). Auf der Dev-Kopie
verifiziert: Kopie trägt rel=canonical auf die Erstquelle und fällt aus der
Sitemap ihres Portals.

E10: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten und die
API-Doku lagen auf allen drei Hosts mit identischem Text und je eigenem
Self-Canonical. Jetzt führt presseecho die Informationsseiten, der Hub die
API-Doku; Sitemap und llms.txt nennen nur die führende Adresse.
Rechtstexte (Impressum, Datenschutz, AGB, Richtlinien, Meldeweg,
Kündigung) bleiben bewusst je Host — jede Domain ist ein eigenes Angebot
mit eigener Anbieterkennzeichnung.

Bei der Erreichbarkeitsprüfung des Hubs gefunden und behoben:
/de/preise, /de/veroeffentlichen, /de/newsrooms und /de/suche wurden unter
pressekonto.com mit dem Branding von businessportal24 ausgeliefert (der
Portal-Kontext fällt dort darauf zurück) — jetzt 301 auf presseecho, die
Transparenz-Navigation verlinkt direkt dorthin. /de/kontakt und
/de/ueber-uns sprangen über /de auf die Startseite, jetzt in einem Schritt.

Der Self-Canonical kommt jetzt aus dem aufgerufenen Host; Portal-Inhalte,
die über den Hub laufen, behalten ihr Portal (webPortalContextApplied).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:52:09 +00:00
4edb915a9a web: Doppelveröffentlichung — Prüfsumme, kanonische Zuordnung (aus bis E9), Quellenauskunft, noindex im Portal (Anhang 2)
Über die API kommt praktisch jede Meldung zweimal, je Portal eine Zeile
mit gleichem Text: 9.092 Paare, 53 % des Archivs, bisher jede Fassung
self-canonical und in beiden Sitemaps — Google wählte.

1c.7: press_releases.content_hash (SHA-256 über Titel + Text ohne Markup),
gesetzt beim Speichern, Bestand per press-releases:hash-content
(Query-Builder, Blöcke). Migration in zwei Statements — ADD COLUMN INSTANT,
ADD INDEX INPLACE, kein Rebuild. press-releases:duplicates-report zählt
Paare je Hauptkategorie.

1c.8: canonical_press_release_id + CanonicalAssigner. Erstquelle folgt der
Kategorie (config/canonical.php, Vorschlag zu E9), sonst die zuerst
veröffentlichte Fassung. Zuordnung bei jeder Veröffentlichung und über den
Bestand (press-releases:assign-canonicals). Die Kopie trägt rel=canonical
und mainEntityOfPage auf die Erstquelle; Sitemap und News-Sitemap nennen
nur sie; Leser sehen weiterhin beide. Alles hinter
CANONICAL_ASSIGNMENT_ENABLED (false), bis E9 entschieden ist.

1c.9: user_canonical in den Index-Prüfungen, sourceVerdict() unterscheidet
eigene Adresse / andere eigene Fassung / fremdes Portal; Anzeige im Block
„Google-Index" hinter GSC_CUSTOMER_VISIBLE, ohne Aussage über das Warum.

Dazu: Abschnitt zur einen Quelle auf /gute-pressemitteilung (Verteiler →
viele Kopien, eigene Website zuerst, keine Garantie), noindex in Admin-,
Kunden- und Auth-Layouts, UmamiSnippetTest von der lokalen .env gelöst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:09:16 +00:00
375138679d web: Erstquelle-Absatz in der Veröffentlichungsmail, vierte Transparenzseite „kostenlos veröffentlichen" (Anhang 1: 1d, 2.4, E7)
Meldungen, die über die API eingegangen sind, bekommen in der
Veröffentlichungsmail einen Absatz zum Unterschied Erstquelle/Kopie —
technische Tatsache, ohne Preis und Angebot; direkt eingereichte nicht.

/pressemitteilung-kostenlos-veroeffentlichen beantwortet die Suche nach
„kostenlos" ehrlich: kein kostenloser Weg (E7), was kostenlose Portale
leisten, warum Links aus Presseportalen kaum tragen (auch bei uns
sponsored nofollow), warum Annahme ohne Gegenwert und Prüfung sich
ausschließen, wer am Ende zahlt. Die Liste kostenloser Portale ist in
config/transparency.php vorbereitet und bleibt leer, bis geprüfte
Einträge mit Prüfdatum vorliegen.

Unter-Navigation, Hilfe, Sitemaps, llms.txt und die Modelltabelle der
Kostenseite verlinken die neue Seite. Anhang 1 und Protokoll Teil 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:22:05 +00:00
6b8728242f admin: Indexierungsstatus aus der Search Console, Kundenansicht vorbereitet und zu (Anhang 1: 1c.4, E8)
press-releases:check-index-status fragt täglich je Portal ein Kontingent
veröffentlichter Meldungen über die URL Inspection API ab, nach Alter
gestaffelt, nie geprüfte zuerst. Jede Antwort wird als Momentaufnahme in
press_release_index_checks gespeichert; der reduzierte Befund (IndexState)
trennt „noch nicht gecrawlt" von „gecrawlt, nicht indexiert".

Client authentifiziert sich als Service-Konto über ein selbst signiertes
JWT — kein Google-SDK. Admin ▸ Reports ▸ Indexierung zeigt die Verteilung
je Portal getrennt nach Direkteinreichung und Syndikation (Status-Log
source=api; Altbestand ohne Log zählt als direkt).

Das Kundenpanel heißt jetzt „Auslieferung", zeigt Sitemap-Datum und die
Abrufe durch Such- und KI-Dienste mit dem Satz „Ein Abruf ist kein Zitat";
der Google-Index-Block hängt an GSC_CUSTOMER_VISIBLE (false).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:21:51 +00:00
951b6c47fa web: Crawler-Abruf-Protokoll und Panel „Auslieferung" ohne Google-Link (Anhang 1: 1c.0, 1c.1, 1c.6)
Abrufe öffentlicher Meldungen durch bekannte Such- und KI-Dienste werden
je Meldung mit Dienst, gekürztem User-Agent und Zeitpunkt protokolliert —
keine IP. Dienste und Muster stehen in config/crawlers.php. Aufzeichnung
wie der Aufruf-Zähler per defer() und fehlertolerant; Bereinigung hängt
an press-releases:purge-events.

VisibilityStats liefert die Abrufe je Dienst (letzter Abruf, Anzahl) und
verliert den site:-Suchlink (Stufe 1 verspricht nichts, was von Dritten
abhängt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:21:35 +00:00
5f7fbd0c65 web: Hilfeseite "Was eine gute Pressemitteilung ausmacht", Hinweis in der Ablehnungsmail (Roadmap 1d.1, 1d.2, 1d.6)
Ohne kommerziellen Anteil (E6): Anlass, Aufbau, Sprache, Pruefablauf mit
konfigurierbarer Versuchszahl, die sieben Qualitaetsdimensionen offen,
Gewichtung nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD.
DE und EN, Sitemap, Hilfe-Center. Die Ablehnungsmail verlinkt die Seite
auf dem Portal der Meldung mit Herkunft mail_reject.

Config: yellow_to_manual_queue Vorgabe false (Entscheidung 16.09.2026),
max_automatic_attempts = 3 (E3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:59:23 +00:00
3ef1b84600 customer: Sichtbarkeit belegen - Aufrufe im Verlauf, Newsroom-Uebersicht (Roadmap 1c)
- VisibilityStats: Firmen-Kennzahlen ueber alle Meldungen, Aufrufe je
  Meldung (30 Tage), lueckenlose Tagesreihe (Tage vor der Zaehlung sind
  null, nicht 0), Sitemap-seit und Google-Suchlink (Indexierung Variante B).
- Firmenseite: alle Meldungen der Firma statt nur eigene (Beansprucher
  sahen eine leere Liste), Aufrufe-Spalten, echtes Statistik-Panel.
- Meldungsseite: Panel Sichtbarkeit mit SVG-Tagesbalken 30/90 Tage.
- Claim aus der Veroeffentlichungsmail landet auf der Meldung
  (payload press_release_id), Mail-Hinweis erweitert, ein Element (E4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:53:09 +00:00
a00f9dc79a billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf
fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein
Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund:
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md.

Bereinigung (legacy:settle-unpaid, wiederholbar):
- Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete
  Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert.
  Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto
  standardmaessig ausgeblendet.
- pending/reminder (recurring) = offene Vorgaenge: Periode auf den
  Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung
  neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number).
- pending/reminder (single) = abgeschrieben (uncollectible).
- legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus.

Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit
Eintrag im Aktivitaets-Verlauf):
- Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer,
  Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand.
- Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist.
- Bezahlt: Zahlungseingang laut Kontoauszug verbuchen.
- Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel
  gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale
  Kundenmail.
- Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist
  fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen".

Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at,
reminder_count, voided_at, void_reason, replaces_legacy_invoice_number
(inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des
Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber
admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:56:29 +00:00
f581e1eb3a mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne
Veroeffentlichungssperre:

- PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist
  ein 422 an der Legacy-API, deterministisch ohne DNS.
- email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle
  (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund),
  Observer an Company/Contact, Job nach Commit, taeglicher Refresh,
  mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS.
- Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste
  und Domainbefund, automatisch abgelehnte Meldungen mit Grund,
  Text zum Kopieren und CSV. Versendet nichts.
- EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht
  auf den Schwellwert. Statuscode entscheidet vor Diagnosetext.

Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer
66 der 151 Ruecklaeufer verantwortlich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:22:39 +00:00
f9c97ccf2f mail: Rückläufer aus dem Bounce-Postfach lesen (Roadmap 1b.6, Schritt 3)
webklex/laravel-imap als Transport, eigener DSN-Parser nach RFC 3464 auf
der rohen Nachricht: Final-Recipient, Action, Status, Diagnostic-Code und
unser X-Pk-Ref aus der Original-Nachricht. Maßgeblich ist Action —
Zustellbestätigungen sperren niemanden, Freitext-Rückläufer wandern nach
Unparsed vor einen Menschen, Abwesenheitsnotizen nach Ignored.
mail:fetch-bounces alle zehn Minuten; ohne Konto tut es nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:55:30 +00:00
8bf40ee37e mail: zentrale Bounce-Suppression mit Versand-Gate und Circuit Breaker (Roadmap 1b.6, Schritte 1–2)
email_suppressions/email_bounces als Zustand und Ereignis-Log; ein
MessageSending-Listener streicht gesperrte Empfänger aus JEDER ausgehenden
Mail und setzt den Return-Path. Zweiter Aufnahmepfad: SMTP-Ablehnungen im
Worker — 5.1.x sperrt den Empfänger, Relay-Ablehnungen zählen für den
Circuit Breaker, der den Versand anhält statt gesunde Adressen zu sperren.
X-Pk-Ref-Header auf den Mails, API-Meldungen fallen auf die Firmen-E-Mail
zurück, der Bounce-Import schreibt in die zentrale Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:48:56 +00:00
0c1c072487 newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:42:20 +00:00
a0ac32cf95 companies: Adress-Klassifizierung persistieren (Roadmap 1a.2, E1 = 3)
Die Klassifizierung aus dem Trockenlauf wird jetzt nach company_email_groups
geschrieben — eine Zeile je Adresse, denn ob zusammengeführt werden darf, ist
eine Eigenschaft der Adresse und nicht der Firma. Die Zusammenführung (1a.3)
und die laufende Zuordnung neuer API-Meldungen (1a.4) lesen dort, statt den
Trockenlauf jedes Mal neu zu rechnen.

Die Logik liegt jetzt in CompanyAddressClassifier und wird von Bericht und
Persistierung gleichermaßen benutzt. Beide müssen dieselbe Antwort geben —
sonst zeigt der Bericht etwas anderes, als die Zusammenführung später tut.

manual_classification übersteuert die berechnete Klasse und überlebt jede
Neuberechnung. Nötig für Grenzfälle, die nur ein Mensch entscheiden kann:
presse@pr4you.de ist eine echte Agentur, sieht in den Daten aber aus wie eine
Wegwerfadresse. effectiveClassification() ist die einzige Stelle, die
Abnehmer fragen dürfen.

Eigene Tabelle statt Spalten auf companies: Die Tabelle trägt einen
FULLTEXT-Index, jedes ADD COLUMN dort ist ein Rebuild. Die Zuordnung Firma →
Gruppe ist ohnehin nur die normalisierte Adresse, und die eine Stelle, die
festlegt, was "dieselbe Adresse" heißt, ist jetzt Company::emailKey().

E1 ist entschieden: Schwellwert 3, nach drei Live-Läufen. Bei 2 wird jede
zweite Schreibweise zur "Agentur", bei 4 wandern echte Mehrkunden-Adressen in
die Handarbeit. Konfiguriert in config/newsroom.php; Bericht und
Klassifizierung lesen den Wert von dort.

Die Ähnlichkeitsprüfung ist quadratisch und jetzt bei 25 Repräsentanten
gedeckelt — ab da steht die Klasse fest, und yahoo@yahoo.com mit über 4000
Namen hätte sonst neun Millionen Levenshtein-Aufrufe gekostet. Laufzeit über
den ganzen Bestand: 25 s → 3 s, Ergebnis unverändert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 09:11:09 +00:00
45f2d90e4e conversion: Konversionsleiter mit Herkunft auf dem Magic-Link-Token (Block 0)
Die zu messende Strecke führt von einer Mail über presseecho.de per Magic Link
auf pressekonto.com und endet in einer Buchung — drei Wechsel, zwei Domains. Ein
cookiefreies Analytics-Tool verliert die Sitzung genau dort, wo die interessante
Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine
Seitenaufrufe, sondern Zustandsänderungen in dieser Datenbank, bei denen die
Identität ohnehin bekannt ist. Deshalb eigene Ereignisdaten statt Umami; Umami
behält Seitenaufrufe und Suchbegriffe.

`conversion_events` hält Ereignistyp, Herkunft sowie Firmen- und Meldungsbezug
— keine Personen, keine Klartextadressen, keine IP. Der rote Faden durch den
Trichter ist die Firma; für die Frage "an welcher Stufe bricht es ab" reicht das
vollständig.

Der Kern ist die Herkunft auf dem Token (neue Spalte magic_links.source). Ohne
sie weiß die Auswertung am Ende, dass jemand gebucht hat, aber nicht, ob er aus
einer Ablehnungsmail kam. Eine Session überlebt den Domainwechsel nicht, das
Token schon. Die Mail-Links tragen zusätzlich `?src=` und `&pr=`; die
Meldungs-ID ist fälschbar, schaltet aber nichts frei und verfälscht im
schlimmsten Fall nur unsere eigene Statistik. Mit dem Magic-Link-Zugang aus 1b.2
entfällt sie.

Fünf Stufen statt gleichrangiger Punkte, jede mit ihrer Diagnose am Enum: Vier
beliebige Konversionspunkte sagen nicht, WO es hakt. Geschrieben wird an Stufe 1
(globale Middleware — die Mail-Links zeigen auf Hub, Spokes und öffentliche
Seiten, eine vergessene Route wäre eine unsichtbare Lücke), Stufe 2
(Magic-Link-Einlösung), Stufe 4 (Registrierung) und Stufe 5 (beide
Zahlungspfade, je Firma dedupliziert — eine zweite Buchung ist keine erste).
Stufe 3 ist definiert und wird ausgewertet; ihre Schreibstelle kommt mit der
Profilpflege aus 1b.3.

Für alle Schreibstellen gilt: Fehler werden geschluckt. Die Messung begleitet
einen Vorgang, sie ist nicht der Vorgang — eine kaputte Statistik darf keine
Anmeldung und erst recht keine Buchung scheitern lassen.

`conversion:funnel` zählt je Stufe die eindeutigen Firmen, nicht die Ereignisse:
Ein doppelter Klick ist kein doppelter Interessent, und nur mit derselben
Einheit auf allen Stufen ist ein Übergang überhaupt eine Quote. Ereignisse ohne
Firmenbezug stehen daneben statt im Nenner.

Aufbewahrung 395 Tage, zusammen mit der Tabelle festgelegt statt nachgetragen,
täglich im Scheduler bereinigt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 13:12:12 +00:00
6a34ab48ee technik: Aufrufe über ein Ereignis-Log zählen statt im Request
Bisher lief pro Seitenaufruf ein UPDATE press_releases SET hits = hits + 1 im
Request. Auf der über 1 GB großen Tabelle blockiert dieser Schreibzugriff,
sobald dort eine Schemaänderung läuft — die wartenden Requests belegten dabei
PHP-FPM-Worker und DB-Verbindungen, bis die ganze Seite stand.

Der Request schreibt jetzt nur noch in die kleine Append-Tabelle
press_release_events, und selbst dabei wird jeder Fehler geschluckt und
gemeldet: Ein Aufruf-Zähler darf eine Seite niemals langsamer oder kaputt
machen. Die Summierung in press_releases.hits übernimmt gebündelt der
Scheduler.

Der Fortschritt läuft über eine Wasserstandsmarke (höchste bereits verbuchte
Event-ID) statt über einen Zwischenspeicher, damit bei einem Neustart kein
Aufruf verloren geht. Zähler und Marke werden je Durchgang gemeinsam
committet — sonst wäre nur der Durchgang wiederanlaufsicher, nicht der Abbruch
darin, und der nächste Lauf verbuchte dieselben Aufrufe doppelt. Eine Migration
setzt die Marke beim Deploy auf den Ist-Stand; ohne sie würde der erste Lauf
den gesamten Altbestand ein zweites Mal aufaddieren.

Weil das Log damit mit dem Traffic wächst und als einzige Log-Tabelle des
Systems keine Bereinigung hatte, begrenzt press-releases:purge-events es auf
einen Aufbewahrungszeitraum (Default 395 Tage = 13 Monate, damit der
Vorjahresmonats-Vergleich möglich bleibt). Gelöscht wird ausschließlich
unterhalb der Wasserstandsmarke — alles darüber ist noch nicht in hits verbucht
und wäre sonst dauerhaft verloren. Fehlt die Marke, bricht der Lauf ab, statt
zu raten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:05:28 +00:00
f8728c5bca technik: Volltext-Indizes von press_releases in eine eigene Suchtabelle auslagern
Ein FULLTEXT-Index zwingt MySQL bei JEDEM ADD COLUMN auf ALGORITHM=COPY. Auf
press_releases (>1 GB) bedeutet das einen vollständigen Rebuild, während dessen
kein Schreibzugriff möglich ist — im August hat genau das die Seite zum Stehen
gebracht, weil die wartenden Requests PHP-FPM-Worker und DB-Verbindungen
belegten.

Die durchsuchbaren Felder liegen jetzt gespiegelt in `press_release_search`,
die die Indizes trägt. Ihr Schema ist per Konstruktion stabil und wird nie
erweitert; neue Felder gehören nach press_releases, wo sie ohne Volltext-Index
Millisekunden kosten. Synchron hält sie ein Observer (alle Schreibzugriffe
laufen über Eloquent, auch der Importer), reparieren lässt sie sich mit
press-releases:reindex-search.

Der Umstieg ist über mehrere Migrationen geteilt und schaltet erst um, wenn
press_releases seine Indizes verloren hat — bis dahin sucht die Anwendung
unverändert weiter. Die Drop-Migration verweigert den Dienst, solange der
Suchindex unvollständig ist, und verwirft die gecachte Umschalt-Prüfung: Stünde
dort noch der alte Stand, setzte der Rückfallpfad sein MATCH() auf einen
soeben entfernten Index ab (MySQL-Fehler 1191) und jede Suche endete im 500er.

Dazu die betriebliche Absicherung:

- DB_LOCK_WAIT_TIMEOUT (Default 10 s) statt MySQLs einem Jahr. Requests
  scheitern bei einer laufenden Schemaänderung nach Sekunden, statt sich
  aufzustauen, bis keine Verbindung mehr frei ist.
- Sessions und Cache nach Redis, damit eine blockierte MySQL-Tabelle nicht die
  gesamte Anwendung mitreißt.
- db:online-alter als Notausgang (pt-online-schema-change), falls eine der
  kleinen FULLTEXT-Tabellen doch einmal groß wird. Percona-Toolkit ist
  bewusst nicht Teil des Standard-Setups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:04:43 +00:00
9afbedddeb recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal —
rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout
(SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt
der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre,
Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent
(50 %) verhindert die verdeckte Selbst-Depublizierung.

Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau
im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor
wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted
an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D,
neuer abbestellbarer Typ press_release_corrected).

Außerdem:
- Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert
  (Lücke im bezahlten Webhook-Pfad geschlossen)
- Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der
  Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis;
  Store auf report/dsgvo/personal_rights gehärtet
- Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt
  des Web-Formulars
- Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung):
  Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt →
  Firmendaten
- Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als
  Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel,
  Wochenlimit-Hinweis in Text und Bremsen-Meldung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:02:57 +00:00
b4b2e013d6 recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:56:34 +00:00
58c0cc3f23 admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand
- Unterseite /admin/newsletter/abmeldungen listet Newsletter-Opt-outs mit
  Zeitpunkt, Suche und Link zur User-Detailseite
- Kampagnen-Filter: Empfänger einer früheren Kampagne ausschließen
  (Abgleich per User-ID und E-Mail-Snapshot) für Follow-up-Mailings
- Login-Filter kann Monats-Scheiben (von–bis) abgrenzen, um große
  Listen Monat für Monat abzuarbeiten
- Versand läuft in Wellen (Standard 50 Mails / 15 Min, per .env
  justierbar) gegen SMTP-Limits und Blocklisten; Hinweis im Senden-Modal
- web: Firmen-Website im Newsroom & Co. als absolute URL verlinkt
  (Company::websiteUrl), statt relativ auf /newsroom/www.example.de
- kunde: API-Doku-Button öffnet die /de/api-Seite; OpenAPI-Spezifikation
  als eigener, benannter Download (pressekonto-api-v1.yml)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:38:47 +00:00
215cbcbdbf admin: internes Newsletter-Modul mit Filtern, Protokoll und Abmeldung
Neues Modul unter Admin ▸ Newsletter (nur users:manage): Kampagnen mit
Betreff + leicht formatiertem Text (Purifier-Profil newsletter: fett/
kursiv, Listen, Links) im zentralen Mail-Layout. Empfänger-Filter auf
der User-Datenbank: Portal, Legacy-Vertrag (Bestandsschutz), Login
innerhalb/außerhalb X Monaten, Sortierung nach Login/Erstellung — mit
Live-Zähler und Vorschau im Editor.

Versand friert die Empfängerliste als Protokoll ein (newsletter_
campaign_recipients) und stellt pro Empfänger einen Queue-Job ein;
der Opt-out wird im Worker erneut geprüft. Jede Mail trägt den
signierten One-Click-Abmelde-Link (neuer EmailNotificationType::
Newsletter, bestehende Unsubscribe-Infrastruktur inkl. RFC 8058);
die Abmeldung greift sofort für alle künftigen Auswahlen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:26:57 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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