Commit graph

58 commits

Author SHA1 Message Date
f9019eecda reports: Kohortenalter ab Stichtag statt ab --to; legacy:settle-unpaid kennt bereits ersetzte Quellen; dev: Basiswert 0.1 vom Live-System ausgewertet
view-distribution rechnete das Alter der Kohorten ab dem Enddatum des
Fensters, die Zähler stehen aber immer auf heute — mit einem --to in der
Vergangenheit waren alle Kohorten um die Verzögerung des Laufs zu jung.
Das Alter zählt jetzt ab dem Stichtag der Erhebung (Feld stichtag), junge
Kohorten fallen bei altem Enddatum leer aus.

settle-unpaid meldete beim zweiten Lauf einen Posten, dessen Quelle der
erste Lauf schon per MAN-Rechnung ersetzt hatte (superseded), als
Konflikt. Solche Quellen zählen jetzt als already_reissued.

Basiswerte Block 0: Live-Erhebung vom 21.09.2026 (gleiches Fenster,
Median 126/303 statt 98/200) eingetragen, Kohortenzeile mit Alters-
Versatz erklärt, letzter Schritt für 0.1 benannt (Lauf ohne --to).
Protokoll: Nach-Deploy-Schritte, Lesart von Audit und Rechnungslauf.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:32:32 +00:00
44d0bb6f29 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
23811ad4d2 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
d0c7194491 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
2ec61697b0 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
3ea9688c9a admin: Bericht „Indexierung & Abrufe" — Rolle Erstquelle/Kopie, Quellenbefund, Crawler-Abrufe; Schlüsselpfad ab Projektwurzel
Der Bericht meldete auf Live „nicht konfiguriert", während der Lauf
längst Daten holte: der relative Pfad der Schlüsseldatei war im Cron
(Projektwurzel) lesbar, im Web-Request (public/) nicht. Relative Pfade
werden jetzt ab base_path() aufgelöst.

Die Verteilung trennt zusätzlich nach Rolle in der kanonischen Zuordnung
(1c.8): bei einer Kopie mit Verweis ist „ausgeschlossen" der erwartete
Befund. Neu: Quellenbefund über Erstquellen (welche Adresse Google führt,
fremde Hosts), Abrufe durch Such- und KI-Dienste je Dienst, Tag und
Meldung mit CSV. Herkunftsmarker als Join statt korreliertem EXISTS,
Aggregate 15 Minuten gecacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:53:22 +00:00
ea68362310 ki/quota: Hash-Bindung atomar bis in den Publish-Lock, roter Re-Check lehnt ab, Kontingent-Altbestand (Block 7, U02–U04)
- Jobs speichern Ergebnis nur per bedingtem UPDATE auf den analysierten Hash;
  publish() prüft bei ki/scheduler unter der Zeilensperre die jüngste gültige
  Klassifikation und plant sonst eine neue Prüfung ein (StaleClassificationException)
- Re-Check ohne Routing: Rot bei einer PM in Review lehnt wie die Erstprüfung ab
- consumePublishSlot() zählt eine NULL-Periode wie den laufenden Monat;
  Backfill-Migration setzt die Periode für Altverbrauch
- ApiPortalResolutionTest: gemeinsame Kategorie wegen der U10-Prüfung

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:22:22 +00:00
f235156de9 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
ee267c622e 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
f1648a05b0 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
7088d092d8 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
4fd1db7825 press-releases: Mail "wird persoenlich geprueft" beim Block, Blade-Guard fuer @php-Bloecke (Roadmap 1d, Nachtrag)
Der Autor bekommt beim Block nach dem letzten Rot (oder Gelb mit
eingeschalteter Queue) eine kurze Mail statt Stillstand: was passiert ist,
dass nichts zu tun ist, und dass das Ergebnis per Mail kommt. Laeuft ueber
notifyAuthor() wie Veroeffentlichung und Ablehnung (API-Regel, Sperrliste,
adressbezogene Abmeldung, Bounce-Referenz); Opt-out am Typ "abgelehnt".
Neue ConversionSource mail_review fuer den Link ins Konto.

BladePhpBlockGuardTest wendet das Compiler-Muster auf alle Views an und
faengt inline @php( vor einem spaeteren @endphp ab — fand den eigenen
Erklaer-Kommentar im Modal, der nur zufaellig funktionierte. Regel in
CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:05:47 +00:00
35db30e019 mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des
Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet.
Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt
als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst
anfordert, gehen weiter durch; harte Bounces sperren alles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:35:47 +00:00
0692d892e0 press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3)
Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3);
das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin
sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und
entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die
Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen.

Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false).
Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte,
Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung.

Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock
gelesen - vier Stellen auf Blockform umgestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:30:26 +00:00
09ec866aac 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
71d9639491 deps: Composer- und npm-Pakete innerhalb der Constraints aktualisiert
Laravel 12.69, Livewire 4.4, Flux 2.20, Cashier 16.8 (Stripe-SDK 21),
Larastan 3.12; Vite 6.4.3, Tailwind 4.3, Alpine 3.17 - npm audit 0.
Drei neue Larastan-Befunde behoben, drei tote Baseline-Einträge entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 15:04:52 +00:00
0a658bb34b 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
abfcd774e0 newsroom: Seite verschwindet mit der letzten veröffentlichten Meldung (Roadmap 1b.1/1b.5)
Die Sichtbarkeitsregel bestand bereits (Detailseite 404, Verzeichnis und
Sitemap nur mit veröffentlichter Meldung). Neu: archive(),
changeStatusFromAdmin() weg von published und deleteFromAdmin() einer
veröffentlichten Meldung leeren den Seiten-Cache — vorher blieb die
Newsroom-Seite bis zu eine Stunde stehen, während die Depublikation längst
sofort griff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:34:14 +00:00
2391c853e3 reports: Aufrufe nach Alter aufschlüsseln und Basiswerte festhalten
Der Gesamtmedian über ein Zeitfenster taugt nicht als Basiswert. `hits` ist ein
mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der Veröffentlichung
über Wochen weiter — auf dem Bestand liegt der Schnitt in der ersten Woche bei
rund 75 und nach drei Wochen bei rund 230, ohne dass ein Plateau erreicht wäre.
Gemessen wird damit nicht, wie viele Aufrufe eine Meldung bekommt, sondern wie
viel eine zufällig alte Meldung bisher gesammelt hat. Dieser Wert verschiebt
sich mit der Altersmischung des Fensters; zwei Messungen zu verschiedenen
Zeitpunkten wären nicht vergleichbar, und genau das braucht ein Basiswert.

Der Report weist die Aufrufe deshalb zusätzlich nach Alterskohorten aus. Erst
die einzelne Kohorte ("Median der Meldungen im Alter von 28 bis 55 Tagen") ist
in einem halben Jahr identisch erhebbar. Der Altbestand bleibt außen vor — seine
Zähler stammen aus dem Altsystem und haben keine hier nachvollziehbare
Vorgeschichte.

Dazu die am 01.09. auf Live erhobenen Basiswerte und die daraus folgende
Empfehlung zu E2: Rohzahlen. Es gibt keinen Bodensatz, den eine Einordnung
kaschieren müsste (unter 25 Aufrufen liegen 2 % beziehungsweise 0 % der
Meldungen), die Verteilungen sind nahezu symmetrisch, und eine Einordnung gegen
den Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch — sie
verglich eine zwei Tage alte Meldung gegen einen Schnitt aus drei Monaten.

Die Roadmap führte openPR als zweite Zulaufquelle. Das ist korrigiert: openPR
ist der Presse-API-Verteiler von connektar, also derselbe Zulauf. In den Daten
hat genau ein API-Token je einen Zugriff gemacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:40:51 +00:00
aab22401d0 reports: Basiswerte für die Konversionsmessung (Roadmap 0.1 und 0.2)
Beide Werte müssen gemessen sein, bevor die erste Maßnahme aus Block 1 live
geht — ohne Vergleichspunkt wäre jede spätere Bewertung eine Vermutung.

press-releases:view-distribution gibt die Verteilung der Aufrufzahlen je Portal
aus: Median, Quartile, P10/P90 und eine Häufigkeitsverteilung. Ein Mittelwert
allein trägt hier nicht — eine Handvoll Ausreißer zieht ihn nach oben, während
der typische Absender darunter liegt. Genau davor soll E2 schützen, deshalb
wird die Schiefe (Mittelwert gegen Median) ausdrücklich mit ausgewiesen.

Altbestand und seit dem Relaunch eingegangene Meldungen stehen getrennt: Ihre
Zähler haben unterschiedliche Vorgeschichten, und eine Einordnung, die beide
vermischt, vergleicht Äpfel mit Birnen. Mit 410 entfernte Meldungen zählen
nicht mit, eine Meldung auf beiden Portalen bei beiden.

press-releases:rejection-rate gibt die Ablehnungsquote je Einreichungsquelle
aus. Gezählt wird je EINREICHUNG, nicht je Meldung: Ein Übergang nach `review`
eröffnet eine Einreichung, die nächste Entscheidung schließt sie. Eine
überarbeitete und erneut eingereichte Meldung ist damit ein zweiter Vorgang mit
eigenem Ausgang — und genau das ist die Größe, die die Infoseite (1d) später
verändern soll. Gruppiert wird nach dem Einreichenden, nicht nach dem
Entscheider; letzterer ist immer die KI-Prüfung und als Gruppierung wertlos.
Jeder API-Client bekommt eine eigene Zeile, Einreichungen aus dem Kundenbereich
fallen zu "direkt" zusammen.

Noch offene Prüfungen zählen nicht in den Nenner, sonst sänke die Quote allein
dadurch, dass der Zeitraum bis heute reicht. Entscheidungen zu Einreichungen
von vor dem Zeitraum werden gemeldet statt verschluckt — ein zu kurz gewählter
Zeitraum sähe sonst wie eine niedrige Quote aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:57:25 +00:00
e746deda4f technik: Statische Analyse wieder aussagekräftig machen
phpstan lief mit 390 Meldungen außerhalb der Baseline, der größte Teil davon
Falschmeldungen. Ursache: Larastan löste keinen einzigen Cast auf, weil alle
Models ihre Casts über die casts()-METHODE deklarieren (Laravel-11-Stil).
Laravel führt die erst zur Laufzeit mit $casts zusammen, Larastan instanziiert
Models aber ohne Konstruktor — $pressRelease->status galt deshalb als string
und jeder Enum-Vergleich als "immer falsch". Dafür gibt es den Schalter
parseModelCastsMethod, dessen Larastan-Default false ist.

Zweite Sammelursache waren fehlende Generics an den Relations-Methoden: Ohne
sie landete phpstan auf Illuminate\Database\Eloquent\Model und meldete jede
Modell-Eigenschaft und jeden eigenen Scope als unbekannt. Alle 102
Relationen tragen jetzt ihr @return <Related, $this>; das verbessert
nebenbei die Vervollständigung in der IDE.

Zusammen fällt die Zahl von 390 auf 169. Die neu erzeugte Baseline umfasst 166
Einträge, überwiegend Closure-Parameter, die als blankes Builder typisiert
sind, sowie zwei nachgeprüfte Falschmeldungs-Gruppen: die Concerns unter
app/Livewire werden ausschließlich aus Volt-Komponenten eingebunden, die
phpstan nicht analysiert, und Cashier typisiert findBillable() gegen den TRAIT
Billable, was phpstan nicht mit User vereinbaren kann — die Laufzeitprüfung
dort ist richtig und bleibt.

Was die Analyse an echten toten Zweigen zutage gefördert hat, ist mit
entfernt: eine doppelte Null-Prüfung im Nachweis-PDF, Prüfungen gegen
NOT-NULL-Spalten in der Rechnungsstellung und im Vorschau-Controller, eine
immer wahre Bedingung im Logo-Pfad und in RetireAgencyCompanyType, ein
überflüssiges ?? [] auf einer Symfony-Methode, die immer ein Array liefert,
sowie der unerreichbare null-Arm in publicPortalLinks(). MigrateLegacyMedia
deklariert seine Medientypen jetzt als Literal-Union, womit das match ohne
default nachweislich vollständig ist statt nur faktisch.

Damit das nicht wieder abdriftet — die Baseline stand seit Mai unverändert —
läuft die Analyse ab jetzt als eigener Schritt in der Test-Pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:07:51 +00:00
eb1eba4533 mail: Anrede an den tatsächlichen Empfänger binden
Bei Einreichungen über die API geht die Veröffentlichungs- oder
Ablehnungsmail nicht an das API-Konto, sondern an den Pressekontakt der
Meldung oder ersatzweise an die Firmen-Adresse. Die Anrede stammte aber
weiterhin vom Konto — der Empfänger wurde also mit dem Namen des
Einreichers begrüßt, bei connektar mit dem der Agentur.

Adresse und Name werden jetzt gemeinsam bestimmt. Ohne hinterlegten Namen
bleibt er leer und das Template grüßt neutral, statt einen falschen zu raten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:07:23 +00:00
2024df9d28 web: Entfernung mit 410 Gone als Alternative zur Löschung
Rechtlich oder redaktionell veranlasste Entfernungen brauchten bisher die
Löschung — danach antwortet die URL mit 404, als hätte es die Meldung nie
gegeben. Newsroom und Pressemitteilung lassen sich jetzt stattdessen mit
gone_at markieren: Der Inhalt verschwindet aus allen öffentlichen Listen, die
bestehende URL liefert aber einen Tombstone mit Status 410. Die vollständige
Löschung bleibt unverändert daneben stehen und erzeugt weiterhin 404.

Umkehrbar heißt hier wirklich umkehrbar. markGone() überschreibt zwei Felder —
companies.is_active (damit der Newsroom ohne Sonderbehandlung aus
Newsroom-Liste und Sitemap fällt, die beide darauf filtern) und
press_releases.no_export. Beide Vorzustände werden gesichert und beim Aufheben
zurückgestellt, sonst stünde eine schon vorher deaktivierte Firma danach
öffentlich in der Übersicht und eine Mitteilung dauerhaft als "kein Export" da,
ohne dass das je jemand gewählt hätte.

Der Auslieferungs-Cache kennt keine Tags, und die Keys der Listenseiten sind
über Portal, Sprache, Seite und Filter verstreut. Eine Entfernung erhöht
deshalb eine Generations-Nummer im Cache-Key und verwirft damit alle
Seiten-Caches auf einen Schlag — grob, aber Entfernungen sind selten und dürfen
nicht bis zu einer Stunde nachhängen. Der Zähler liegt bewusst außerhalb des
Namensraums, den er invalidiert, und wird zwischen zwei Queue-Jobs neu
gelesen: Ein Worker lebt über beliebig viele Jobs und arbeitete sonst dauerhaft
mit dem Stand seines ersten Zugriffs.

Beim Newsroom läuft die Tombstone-Prüfung bewusst VOR dem Seiten-Cache — eine
Entfernung muss sofort greifen, auch wenn für den Slug noch ein Stale-Eintrag
liegt. Kaskadiert man die Entfernung auf die Meldungen einer Firma, wird der
Cache einmal am Ende verworfen statt einmal je Meldung; sonst zwänge eine
Bestandsfirma mit dreistellig vielen Meldungen die gesamte öffentliche Seite
ebenso oft in den Neuaufbau.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:06:35 +00:00
e6eca9c85f recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
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
04b49c567d 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
4c73ba0a89 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
50d9208ca9 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
991af42d27 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
22975e762e 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
000ae93e3b 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
0b35c3b04d 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
7043ea8ad5 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
92d1ac246f 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
4853d48b83 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
9d6ef50e62 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
06d80b255b 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
3ae57d9dba 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
00f67c4e54 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
a3fd361ece 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
72e6a3c210 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
c387048d7e 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
9b55e11159 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
cc77e604f4 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
110f97db6b 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
244c2abd72 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
5d6a94902b 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
e962cc2213 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
5feaa8a36a 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
69411b4c87 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
c7a4c8bfd4 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