Compare commits

...

119 commits

Author SHA1 Message Date
b1cec167d6 chore: docs/ aus dem Repository — lebt im Obsidian-Vault (Devcontainer-Mount), Q-05 entschieden; entpackte Deploy-Pakete ignoriert
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Die Dateien bleiben in der Historie und im Vault; git rm --cached, nichts
auf der Platte gelöscht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:30:33 +00:00
8a64bd7147 dev: Deploy-Checkliste — Server braucht kein Git
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:59:27 +00:00
33dc9918cc dev: Deploy-Checkliste v1.1.0 — Paket gebaut, Entwicklerdateien aus der Paketliste ausgenommen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:55:49 +00:00
df7ebdca10 mcp: Context7-API-Schlüssel aus .mcp.json und .codex/config.toml entfernt — kommt jetzt aus CONTEXT7_API_KEY
Der Schlüssel stand seit Mai im Repository und auf origin; er bleibt in der
Historie und muss bei Context7 rotiert werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:53:06 +00:00
22b62dfc1f dev: Monitoring-Plan (Phasen 1–3, Entscheidungen M1–M9, Protokoll), Deploy-Checkliste v1.1.0, Runbook um Versionsdatei und Composer-Schritt ergänzt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:53:06 +00:00
8769a71100 monitoring: Betriebsüberwachung Phase 1 — GlitchTip (Server- und Browserfehler), Fehlersignale ohne Exception, /health mit Scheduler-Herzschlag, ntfy-Alarme, fehlgeschlagene Anmeldungen im Verlauf, Umami im Kundenbereich, Versionsnummer
- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
  Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
  Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
  für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
  Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
  login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
  nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
  (signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
  (git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.

Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:53:00 +00:00
f2ed268b51 deps: Composer-Update (Laravel 12.69.3, Livewire 4.4.7, Flux 2.20.1, Fortify 1.40.0), league/commonmark 2.10.3 behebt zwei Advisories; Sentry-SDKs für GlitchTip (sentry/sentry-laravel, @sentry/browser)
Livewire 4.4.7 rendert ModelNotFoundException im Testhelfer als 404 statt
sie zu werfen — zwei Tests auf assertNotFound() umgestellt, Laufzeitverhalten
unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:52:48 +00:00
2701588ac1 dev: Commit-Hashes der Blöcke 9 und 10 im Protokoll nachgetragen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:13 +00:00
aa47e113fe dev: Umsetzungsaudits 29.09. und 02.10., Entwicklungsplan Blöcke 9 und 10 mit bestätigten Entscheidungen E19–E34 und Deploy-Plan, Runbook ergänzt
- UMSETZUNGSAUDIT-2026-09-29.md (Bestandsaufnahme) und -2026-10-02.md
- ENTWICKLUNGSPLAN.md: Block 9 und 10, Entscheidungen E19–E34 vom Betreiber
  am 02.10.2026 bestätigt (E25 revidiert), Deploy-Plan A–E, Protokoll
- deploy-runbook.md: vollständige Scheduler-Tabelle, Restore-Schritte mit
  --accept-unsigned/--without-export, Zugangsdaten aus Abschnitt 7 entfernt
  (stehen weiter in der Historie — Passwort wird rotiert)
- Bildplan-Protokoll mit Korrektur zu „Altbestand geschützt" (A-05)
- dev/README.md und dev/roadmap.md verweisen auf den aktuellen Stand
- .env.example: neue Schlüssel aus Block 9/10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00
5ec252da12 images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit
  isPubliclyDeliverable()
- B-03: Livewire-Temp-Upload 16 MiB
- N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put()
  geprüft, alle Fassungen ins Archiv
- O03: published_ever_at in der Freigabetransaktion
  (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur
- B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre
- B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag;
  Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert
- B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme)
- B-08, B-09, B-11, B-12: published_ever_at nur bei Status published,
  Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale,
  Verarbeitungsfehler als Formularmeldung
- K-02-Teil: nach Kundenbestätigung erneut routen
- OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame

Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08
(Nachweis per POST), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00
7cd84b2be3 archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00
f0eeef0144 press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00
c2e0b85242 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00
1d417f87f7 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00
dfb0513e73 tests: gemeinsamer Testhelfer, DB_URL-Guard vor RefreshDatabase, Larastan voller Scope ohne Meldung (Umsetzungsaudit 29.09.: Q-01, Q-02; 02.10.: O02)
- makeCustomerForShowPhase8a() nach tests/Pest.php — die Suite läuft damit
  auch parallel (--parallel) und dateiweise grün
- phpunit.xml leert DB_URL, der Guard in TestCase::setUpTraits() bricht bei
  gesetzter url ab, bevor RefreshDatabase eine Verbindung öffnet
- HasUniqueSlug ohne widersprüchliche @var-Annotation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00
98c465b691 dev: Bildkennzeichnung — Deploy 1b live (Formular v2 an, Testkonto geprüft), Precheck und Gate bleiben aus
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 13:08:15 +00:00
21e88710b6 dev: Bildkennzeichnung — Deploy 1, 2 und 2b am 28.09.2026 live, Protokoll
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 13:04:26 +00:00
97cd8f9e90 dev: Entscheidungen E10–E18 vom Betreiber bestätigt (25.09.2026)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 15:24:10 +00:00
b80cce6c37 dev: Entwicklungsplan Block 8 — Abgleich Umsetzungsaudit 25.09., Entscheidungen E10–E18, Live-Schritte, Protokoll
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:27:12 +00:00
48129e99a0 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00
4e34c3d0d1 tests/legal: Testdatenbank-Guard vor den DB-Traits, Flags in phpunit.xml gepinnt, Zustimmung an gezeigte Fassung gebunden (Umsetzungsaudit 25.09.: N22, N06, 5.3)
- Tests\TestCase prüft die Standardverbindung in setUpTraits(), also vor
  RefreshDatabase — bisher erst nach dem Refresh (N22). Eigener Test.
- phpunit.xml pinnt IMAGE_RIGHTS_V2, IMAGE_PRECHECK(_STAGE) und
  LEGAL_ACCEPTANCE_GATE auf die Vorgaben aus .env.example; die Suite hing
  sonst an der lokalen .env (Audit Abschnitt 6).
- Zustimmungsgate: die Livewire-Komponenten tragen die beim Rendern gezeigten
  Fassungen (Trait-Hook rendering); weicht die aktuelle Fassung beim Absenden
  ab, wird die Annahme abgelehnt und der neue Wortlaut gezeigt. Ein fehlendes
  Pflichtdokument ist bei aktivem Gate ein Konfigurationsfehler (N06).
- Audit vom 25.09., Decision-Update vom 22.09. und Plan Fassung 2 versioniert;
  die Antwortkopie „…-211.md" entfernt; U10-Formulierung im Plan präzisiert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 13:35:14 +00:00
d99f55e555 admin: Management ▸ Bilder — Liste aller Titelbilder mit PM-Link, Nachweis, Kennzeichnung, Bestätigungsstand und KI-Vorab-Check, Filter nach Sichtung/Zustand/Ausgang (Bildkennzeichnung)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 12:46:33 +00:00
6d558cd26a web: KI-Badge am Artikelbild nach rechts unten, damit es das Nachweis-Zeichen links nicht überlappt (Bildkennzeichnung D.2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 12:34:41 +00:00
0e5cd20626 images: KI-Vorab-Check für Titelbilder — Vision-Sichtung mit definiertem Ausgang je Befund, Admin-Queue, Protokoll und Archiv-Snapshot (Bildkennzeichnung Block K)
Decision-Update Abschnitt 12 (E15, S24): Sichtung auf offensichtliche
Probleme, keine Rechteprüfung, öffentlich weiterhin „automatisierte Prüfung".

- ImagePrecheckManager mit openai- und deterministic-Treiber (Muster der
  Text-Prüfstrecke), Bild auf 1024 px, Metadaten gestrippt, JPEG als Data-URI,
  Antwortschema 12.4; Fehler → Ausgang `error`, durchlassen mit Vermerk.
- Auswertung 12.3: reject (Stock-Wasserzeichen, Nacktheit/Gewalt, Dokument
  ab 0,85), queue (Screenshot, fremdes Logo, Kennzeichen ab 0,6 sowie
  Ablehnungskategorien 0,6–0,85), hint (Personen, „wirkt KI" nur bei „ohne
  KI", technischer Mangel). Stufe 1 wertet nur das Wasserzeichen; die KI setzt
  nie die Kennzeichnung (E9). Herabstufung per Config (12.8).
- Uploader: Check vor jedem Schreiben, Ablehnung speichert nichts und
  landet als ki_audits `image_precheck` an der PM; Hash-Bindung zwischen
  Vorprüfung im Formular und Speichern; Spalten precheck*, Snapshot
  `precheck_snapshot` im Bestätigungsprotokoll, Archiv-Payload erweitert.
- Queue: Bild zurückgehalten (withheld_at), Meldung läuft weiter; Admin
  entscheidet mit Vermerk (Freigabe/Ablehnung → ki_audits
  `image_precheck_review`); Ansicht „Bild-Sichtung" in der PM-Liste,
  Sichtungsblock auf der PM-Seite, Systemstatus-Zähler.
- Formular v2: Ablehnungsgrund unter dem Dateifeld, Hinweise nach der
  Dateiauswahl, „wird kurz gesichtet" an der Karte; API 422 mit Grund,
  Warnung `image_under_review`.
- Config images.precheck (IMAGE_PRECHECK aus, STAGE 1); 17 Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:27:31 +00:00
3b613fadf4 images: press-release-images:regenerate-variants --only=og für den Bestand, Testhelfer eindeutig benannt (Bildkennzeichnung H.1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:13:49 +00:00
62723b551a legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)
- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:12:17 +00:00
0240c143c8 archive: Beweisarchiv — verschlüsselte Einträge, Legal Hold, Purge mit Tombstone, Restore-Abgleich, Auslöser und Admin-Seite (Bildkennzeichnung Block F)
- archive_entries (append-only) + archive_access_log; Disk archive außerhalb des Web-Roots; eigener ARCHIVE_KEY (archive:key), AES-256-CBC, SHA-256 am Eintrag
- EvidenceArchive: record/open/manifest/export (signiertes ZIP), setLegalHold/releaseLegalHold (Frist läuft neu), purgeDue, exportTombstones, reconcileAfterRestore
- PressReleaseArchiver: image_removed (Original, Snapshot, Bestätigungen), pm_depublished (letzter veröffentlichter Stand), gdpr_change (Anonymisierung, Korrekturen), label_corrected
- Hooks in Remover, depublish/archive/changeStatusFromAdmin/deleteFromAdmin, AnonymizationService, CorrectionService, ImageCorrectionService
- Scheduler archive:purge 03:20 und archive:export-tombstones 03:30; archive:reconcile-after-restore im Runbook
- Admin ▸ Management ▸ Beweisarchiv (Liste, Legal Hold mit Pflichtvermerk, Öffnen/Export nur mit Zweck)
- Scope-Wächter: neue Befehle nutzen withoutGlobalScope(PortalScope::class)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:04:21 +00:00
46b89e1d23 images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)
- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung)
- deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1)
- press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard)
- Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien
- ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite
- Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:53:49 +00:00
2124ffd1f2 web/admin/pdf: gemeinsame Bildnachweis-Auflösung, Detailfenster am Artikelbild, Bestätigungsstand in Kunden- und Adminansicht, Wortwahl „automatisierte Prüfung" (Bildkennzeichnung Block D)
- App\Support\ImageCredit: Foto/Quelle-Zeile, KI-Hinweis mit Herkunft, Verantwortlich-Zeile nur mit Protokoll, sichere Links; PDF-Zeilen mit Bestätigungskennung
- Artikelseite: Overlay (eigenes i-Zeichen, Alpine ohne Plugin, Bottom-Sheet auf Mobil) mit Nachweis, Lizenz, Quelle, KI-Hinweis, Verantwortlich-Zeile, „Bild melden"
- Richtlinien 0.2: Prüfung als automatisiert beschrieben, Abschnitt „Bilder" (Decision-Update 3.5); Taglines, Suche, Newsrooms, Startseiten-CTA, Mails ohne „qualitätsgeprüft"
- Cache-Invalidierung bei Bildwechsel an veröffentlichten Meldungen; PHPStan-Baseline für das Resource bereinigt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:44:34 +00:00
65ac72ce05 api: Bildupload v1 unter Formular v2 (Pflichtfelder, Kennung des Wortlauts, Bestätigung mit Token), Legacy-Bildupload abgeschaltet, API-Doku (Bildkennzeichnung Block C)
- StorePressReleaseImageRequest: copyright, rights_confirmed, confirm_text_version Pflicht; 422 mit aktueller Kennung; 16 MiB
- PressReleaseImageController über den gemeinsamen Uploader, Löschen über den Remover; Resource mit Portalkennzeichnung, Kundenangabe, Herkunft, Bestätigung und warnings[]
- POST /service/pressreleases/{id}/images antwortet 410 mit Hinweis auf v1 (E-B4)
- API-Seite und Sprachdateien: Felder und Rechtehinweis mit aktiver Textkennung; Paritätsdokument ergänzt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:35:52 +00:00
40953d6c65 images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)
- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error
- CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link
- PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt
- ConversionSource::MailImageConfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:31:32 +00:00
e9bdff3ad3 images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet
- legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations
- image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads
- ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error
- PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung
- ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:22:05 +00:00
b624f80d7f assets: Inter (18pt-Schnitte) mit OFL 1.1 für den eingebrannten Bildnachweis (Bildkennzeichnung O4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:09:25 +00:00
605d853ff2 dev: Roadmap Block 0 — 0.2 bis 0.6 als erledigt geführt (Stand Protokoll Teil 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 16:11:16 +00:00
5ef123c3c7 dev: Basiswert 0.1 abgeschlossen — Kohorte 28–55 Tage am Stichtag 21.09.2026 (Median 122 / 314), Nach-Deploy-Stand
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:54:53 +00:00
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
dab14dc810 press-releases: assign-canonicals leert Seiten- und Sitemap-Caches; dev: Live-Lauf 21.09.2026 protokolliert (9.221 Paare)
Der Nachlauf über den Bestand änderte rel=canonical und die Sitemap-Auswahl,
ließ aber gecachte Meldungsseiten (1 h stale) und Sitemap-Caches (1 h)
stehen. Nach einem Lauf mit Änderungen wird WebCaches::forgetPublicPages()
aufgerufen; der Test prüft das über den vorher gecachten Sitemap-Stand.

Protokoll Teil 11: Nachtrag Live-Deploy (hash-content 179.619,
duplicates-report 9.221 Paare / 1.468 seit 07/2026, assign-canonicals
9.223/9.221, basiswert-0.1.json auf dem Server), Anhang 2 1c.7/1c.8 mit
Live-Zahlen, Basiswerte 0.1 mit Hinweis auf die noch zu holende Datei.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 13:05:43 +00:00
e620ef301f web: Datenschutzerklärung — Abschnitt 5a „Eigene Nutzungsstatistiken ohne Personenbezug", Frist 395 Tage (Roadmap 0.7)
Aufrufe/Teilen je Meldung, Abrufe durch Such-, KI- und Feed-Dienste,
Konversionsschritte: Umfang ohne IP, Zweck, Art. 6 Abs. 1 lit. f, Löschung
nach 395 Tagen in Abschnitt 13. Text vom Betreiber abgenommen (21.09.2026);
gilt für Hub und beide Portale. 0.7 in Roadmap und Protokoll abgehakt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:40:10 +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
b423650d80 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
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
b8c6af5f10 auth: automatische Firmenzuordnung beim Wegfall der E-Mail-Grundlage entfernen statt senken (Block 7.13, U01, E7)
- Login-Sync löscht auto-Pivots ohne aktuellen Treffer; member wäre die
  Teamrolle mit Schreibrecht auf alle Firmen-PMs, keine Leserolle
- hochgestufte manuelle Mitgliedschaften tragen granted_via=auto-upgrade
  und fallen auf member zurück; manual und owner bleiben unberührt
- company-memberships:audit --classify stuft nur manual-Zeilen um
- Plan: E2 revidiert, E7 entschieden, 1.5 und 7.13 nachgezogen

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:00:48 +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
7819527c12 dev: Umsetzungsaudit vom 18.09. abgelegt, Block 7 mit Abgleich, Entscheidungen E7–E9 und Protokoll im Entwicklungsplan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:22:43 +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
1cd9fd11af billing: Vertragsablauf bedingt beenden, Kundenmail nachholbar (Block 7, U11/U12)
- Beenden je Kunde in einer Transaktion mit erneuter Prüfung aller
  Auswahlbedingungen unter Zeilensperre; Ereignis nur für getroffene Zeilen
- Marker grandfathering_expired_at/-notified_at; Mail in zweiter Phase,
  bei Fehler beim Einplanen FAILURE und Nachholen im nächsten Lauf

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:03:04 +00:00
235d3928ab 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
fb03fb166d dev: Protokoll — Gesamtsuite grün, zwei zufallsabhängige Tests festgezurrt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:56:21 +00:00
c882194330 tests: Titel in der MAN-PDF-Adresse festlegen
Die Factory würfelt optional „Dr.“/„Prof.“; der Test prüft „Frau Erika
Muster“ wörtlich und schlug in etwa drei von vier Läufen fehl.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:56:14 +00:00
ec6705ee99 tests: Kategorie-Portal in den API-Tests festlegen
Die Kategorie-Factory würfelt das Portal; seit der Portal-Prüfung im
API-Store (Audit F11) schlug 'api submitted html text is sanitized'
zufällig mit 422 fehl.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:48:30 +00:00
4fa9859024 dev: Umsetzungsprotokoll und Live-Schritte zum Audit-Entwicklungsplan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:41:58 +00:00
375f4dffce billing/dev: Vertragsende in der MAN-Abrechnung, Rechnungslauf ohne Tageskappe, Qualitätsgates (Audit Block 5+6, Deploy E)
Block 5 — MAN-Abrechnung

F06 (E6): Ein explizites Legacy-Vertragsende (grandfathered_until) deckelt
jetzt Auswahl und Sperr-Nachprüfung: Beginnt die nächste Periode nach dem
Vertragsende, gibt es keine Rechnung mehr. Neuer täglicher Befehl
billing:expire-grandfathering (04:15, vor dem Rechnungslauf) setzt
abgelaufene Posten auf gekündigt und schickt je Kunde eine Mail
(GrandfatheringExpired). Auf der Dev-Kopie trägt kein Posten ein
Vertragsende; der Fehler war latent.

F16: Nicht abrechenbare Vereinbarungen bekommen einen Sperrvermerk
(billing_hold_reason/-at), sichtbar auf der Admin-Zahlungsseite, und
stehen im Rechnungslauf hinten an. Der Lauf arbeitet den fälligen Bestand
in Seiten ab; --limit deckelt die erstellten Rechnungen (500), nicht die
betrachteten Fälle. Vorher blockierten 50 unvollständige Fälle die 51.
dauerhaft, und der Durchsatz war auf 50 am Tag begrenzt.

Block 6 — Entwicklung

F24: Lint-Workflow prüft mit pint --test statt zu formatieren; keine
Schreibrechte mehr.
F27: composer run dev startet beide Vite-Server (npm run dev:all).
F28: docs/README.md als historischer Stand markiert, toter Link auf das
Hub-Flux-Archiv korrigiert, aktueller Einstieg dev/README.md.
F26: Login-Modal übernimmt den Fokus, hält Tab im Dialog und gibt den
Fokus beim Schließen an den Auslöser zurück – ohne neue Abhängigkeit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:41:04 +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
62202a4b13 auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)
F10: company_user.granted_via unterscheidet automatisch vergebene
Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login
senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail-
Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff
mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder
geänderte Zeilen als manual, unveränderte behalten ihre Herkunft.

Bestandsprüfung: php artisan company-memberships:audit listet
responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV
vollständig) und markiert mit --classify die erklärbaren als auto. Auf
Live nach der Migration einmal mit --classify laufen lassen; nicht
erklärbare Zeilen bleiben manual und damit geschützt.

F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern
und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen
Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das
Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können
die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink
ohnehin einlösen. Fünf Fehlversuche pro Minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00
a7f991f01e newsletter: jeden Empfänger genau einmal, atomarer Start, Wiederanlauf (Audit Block 2, Deploy B)
F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).

F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.

F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.

Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00
0ab53bfa5e 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
24c1bbf500 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
4c17a17254 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
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
447dc4c61e web: Hilfe in der Topleiste neben FAQ; 2.5/E5 geschlossen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:40:41 +00:00
10c91ac3f5 docs: Search Console live, Schlüsseldateien ausgeschlossen, Kontingent erklärt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:29:19 +00:00
b44fdedbdf admin: URL Inspection immer auf en-US abfragen
coverageState ist ein übersetzter Anzeigetext; mit de-DE kam „Seite mit
Weiterleitung" zurück und die Befund-Zuordnung griff ins Leere. Die
Enum-Felder sind sprachunabhängig, der Text nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:55:11 +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
6c4618afed 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
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
6446e19727 web: Unter-Navigation Preise/Transparenz auf allen vier Seiten mit aktivem Punkt
"Tarife & Preise" als erster Punkt; x-web.static-page bekommt einen Slot
`aboveTitle`, die drei Transparenzseiten tragen dieselbe Leiste mit
markierter aktueller Seite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:23:42 +00:00
1fd6d2c0de web: Transparenz-Navigation ueber der Headline der Preisseite (Roadmap 2.4)
x-web.transparency-nav (Label + drei Chips) statt der kleinen Zeile unter
dem USt-Hinweis; Komponente kennt `current` fuer spaetere Wiederverwendung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:12:22 +00:00
b6e4bc5dc0 web: Transparenzseiten — was kostet, ohne Abo, loeschen/aendern (Roadmap 2.4)
Drei Seiten mit unterschiedlicher Suchintention, DE/EN, Hub + Spokes:
- /was-kostet-eine-pressemitteilung: kurze Antwort im ersten Bildschirm,
  vier Abrechnungsmodelle ohne Anbieternamen und fremde Preise, sechs
  Prueffragen fuer Folgekosten, "Was bei uns gilt" (sieben Zusagen mit
  Einloesung), Rechenbeispiel Einzelabrechnung gegen Starter-Monate mit
  Zeilen fuer beide Seiten.
- /pressemitteilung-veroeffentlichen-ohne-abo: Ablauf, enthalten / nicht
  enthalten, nichts verfaellt, wann sich ein Tarif lohnt.
- /pressemitteilung-loeschen-oder-aendern-lassen: fuenf Wege als
  Tabelle (Korrektur, Aenderung, DSGVO, Depublikation, DSA), Antragsweg
  ueber /melden/pm, Korrektur statt Loeschung, Absatz fuer Meldungen auf
  fremden Portalen ohne Zusagen ueber Dritte.

PricingTransparencyData liefert alle Zahlen aus config/billing, credits,
legal und dem Starter-Tarif; TransparencyPageController leitet die Marke
aus dem Host ab. Tabellen-Typografie fuer .pm-body. Verlinkt aus
Preisuebersicht, Hilfe-Center, Sitemaps und llms.txt.

Test TransparencyPagesTest (9) erzwingt u. a., dass im Rechenbeispiel
beide Modelle Zeilen gewinnen. Protokoll Teil 5, Roadmap 2.4 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:00:28 +00:00
84ea617107 web: Jahresarchiv je Branche, Kategoriebeschreibungen, Portalname im Titel (Roadmap 2.3)
- /de/category/{slug}/archiv/{jahr} (CategoryArchivePageData,
  CategoryArchiveController, View kategorie-archiv): 30 Meldungen je
  Seite, neueste zuerst, Jahresleiste mit Zahlen; ohne Jahr das juengste
  Jahr mit Meldungen. Canonical mit ?page=N auf Folgeseiten, hreflang
  nur auf Seite 1, CollectionPage + BreadcrumbList. Gecacht bis Seite 3.
  Leere Jahre, unbekannte Kategorien, leere Folgeseiten: 404.
- Branchenseite: Jahresleiste unter dem Feed (archiveYears, eine
  gruppierte Query); Sitemap listet alle Jahresarchive.
- categories:seed-descriptions {--dry-run} {--force}: fuellt leere DE/EN
  Beschreibungen aus database/seeders/data/category-descriptions.php
  (14 Kategorien, sachlich, ohne Werbeaussagen), leert den Seiten-Cache.
- Branchenseite: Titel trug config('app.name') ("– pressekonto") statt
  des Portalnamens — jetzt presseecho/businessportal24.
- Protokoll Teil 5 (2.3 mit Befund: 0 von 14 Beschreibungen, Sonstiges =
  28 % des Bestands), Roadmap 2.3 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:25:29 +00:00
b74f881ac8 web: Entitaeten im JSON-LD verknuepft, News-Sitemap und llms.txt (Roadmap 2.1, 2.2, 2.7)
- App\Support\StructuredData: Portal-Organization als Publisher mit @id
  und Logo (ImageObject), Newsroom-@id; NewsArticle.author auf der
  Meldung und Organization auf der Newsroom-Seite beschreiben jetzt
  dieselbe Entitaet. Startseiten nutzen denselben Publisher.
- /sitemap-news.xml je Spoke (NewsSitemapController): im Archiv
  sichtbare Meldungen der letzten 48 h, max. 1.000, news:publication
  mit Portalname und Sprache; 15 min gecacht, mit den anderen Sitemaps
  geleert; in robots.txt der Spokes. Hub liefert leere Liste.
- /llms.txt je Host (LlmsTxtController): sachliche Beschreibung,
  URL-Aufbau, aktive Kategorien, Seiten fuer Unternehmen, Rechtliches;
  Hub verweist auf beide Portale. Keine Aussagen ueber KI-Nennungen.
- Protokoll Teil 5 (Block 2) inkl. Betreiber-Schritten fuer das
  Publisher Center und Befund zu 2.5/E5; Roadmap 2.1/2.7 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:15:28 +00:00
713024be37 companies: Befehl zum Deaktivieren der Legacy-Linkspam-Registrierungen
4.391 aktive Firmen (2010-2022) tragen bbcode-/HTML-Linkspam in den
Stammdaten und haben nie eine Meldung veroeffentlicht. Sie werden
deaktiviert, nicht geloescht; IDs landen in storage/app, Besitzerkonten
bleiben unangetastet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:56:44 +00:00
99ffd012d4 companies: Links aus Firmennamen normalisieren — API-Eingang und Bestandsbefehl
38 oeffentliche Firmen tragen eine URL im Namen ("Firma - https://..."),
laufend ueber die API. Nicht ablehnen (traefe die Meldung), sondern
normalisieren: URL raus, Rest bleibt Name, URL ins leere Website-Feld;
besteht der Name nur aus der URL, wird der Host zum Namen. Am API-Eingang
vor der Zuordnung; im Bestand per companies:strip-links-from-names mit
Dry-Run, Slugs bleiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:07:49 +00:00
87ca9082a9 companies: Kurzfelder des Profils ohne Links, Adressen und Spam (NoPromotionalText)
Firmenname, Adresse und Pressekontakt erscheinen oeffentlich bzw. in
Mails, wurden aber nicht geprueft — nur die Beschreibung lief durch die
Rot-Ebene. Neue deterministische Regel: keine Links, keine E-Mail-
Adressen, keine Zeichen-/Emoji-Ketten, kein Blacklist-Treffer; nackte
Domains ohne Pfad bleiben erlaubt (Marke). Adresse zusaetzlich durch die
Rot-Ebene wie die Beschreibung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:55:07 +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
c08c9e5de0 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
58bc82a91f billing: tote Null-Pruefungen auf NOT-NULL-Spalten entfernt (phpstan)
current_period_start/-end und invoice_date sind NOT NULL; die nullsafe-
Zugriffe und der Fallback 'n/a' konnten nie greifen. Suite wieder auf
"No errors".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:34:56 +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
36691a5b13 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
ada41cb067 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
fddfd8a61c mail: Bounce-Abruf liest RFC 6533, All-Inkl-Freitext und ganze INBOX
Befund aus 151 echten Rücklaeufern: 2 SmarterMail-DSNs
(message/global-delivery-status) und 2 Freitext-Hinweise des eigenen
Relays (X-KASMail-Error, formal auto-replied) fielen durch. Policy- und
Quota-Codes zaehlen weich statt hart. Abruf ohne UNSEEN-Filter, weil
das Verschieben die Zustandsmarke ist.

Dazu Schutz in tests/TestCase.php: Suite bricht ab, wenn ein Config-Cache
sie auf die Dev-Datenbank umlenkt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 15:27: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
e84c2772ed admin: Mail-Sperrliste, Markierung nicht erreichbarer Newsrooms (Roadmap 1b.6, Schritt 4)
Admin ▸ Mail-Sperrliste mit Freigabe, manueller Sperre, nicht lesbaren
Rückläufern und Breaker-Zustand. Firmenseite markiert gesperrte
Firmen-E-Mail und Pressekontakte. Die Claim-Seite zählt gesperrte
Adressen nicht mehr als Empfänger und erklärt, warum kein Link geht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 14:01:07 +00:00
e80006589c 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
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
87f95fb7a7 newsroom: Beschreibung pflegen, durch die Rot-Ebene geprüft (Roadmap 1b.3)
Die Firmenseite im Kundenbereich bekommt den Beschreibungstext (max.
2 000 Zeichen, reiner Text). Er läuft durch dieselbe Klassifikation wie
eine Meldung (CompanyProfileCheck, KI mit deterministischem Fallback);
Rot wird abgewiesen. Links im Profiltext gibt es nicht — die Website ist
das eine Link-Feld. Bilder zurückgestellt (BALD-Platzhalter), bis die
Bild-Kennzeichnung angepasst ist.

Stufe 3 der Konversionsleiter (newsroom_edited) wird hier geschrieben,
einmal je Firma, mit der Herkunft des zuletzt eingelösten Magic Links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:04:12 +00:00
079477a423 web: Melden- und Verwaltungsblock auf der Meldungsseite hervorheben
Die beiden Hinweise unter der Mitteilung („Inhalt melden“, „mit der
hinterlegten E-Mail anmelden“) waren Textzeilen in 11,5 px und wurden
überlesen. Jetzt zwei klar getrennte Felder mit Buttons; der
Verwaltungsblock nennt die maskierte Empfängeradresse und führt auf die
Claim-Seite des Newsrooms. Derselbe Hinweis noch einmal in der
Seitenleiste unter „Weiterführend“, wie in der Veröffentlichungsmail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:58:39 +00:00
01014887cf newsroom: Hinweis „Diesen Newsroom verwalten“ auf der Seite und in der Mail (Roadmap 1b.4, E4)
E4 (15.09.2026): Die Veröffentlichungsmail trägt den Hinweis am Ende, als
Hinweis, nicht als Angebot. Auf der Newsroom-Seite eine Zeile unter dem
Profil. Der Mail-Link ist mit src=mail_publish getaggt; die Claim-Seite
reicht die Herkunft bis auf das Token durch, sodass die Leiter „aus der
Mail beansprucht“ von „auf der Seite entdeckt“ unterscheidet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:05:53 +00:00
9143cc4a54 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
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
0be476b821 dev: Block 1a in der Roadmap als erledigt markiert, 1a.4-Entscheidung festgehalten
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:32:33 +00:00
8baca9dd78 api: neue Firmen dem bestehenden Newsroom zuordnen (Roadmap 1a.4)
connektar legt je Meldung eine Firma an. ApiNewsroomAssignment prüft vor
dem Anlegen, ob unter derselben Firmen-E-Mail schon dieselbe Firma steht
(Namensregel der Klassifizierung), und gibt dann deren ID zurück — der
Alt-Vertrag (201 + Location) bleibt. Nie-veröffentlicht-Adressen werden nie
zugeordnet, abweichende Namen bekommen wie bisher eine neue Firma. Liegt
die Firma auf dem anderen Portal, wird sie both; bei fremd belegtem Slug
bleibt es beim alten Verhalten.

LegacyPressReleaseController: both ist kein Ziel-Portal für eine Meldung —
dann entscheidet das Portal des API-Nutzers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:26:13 +00:00
d60f31d78e dev: kompletten Live-Lauf der Zusammenführung protokolliert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:15:07 +00:00
cd16fd14f3 dev: Live-Läufe der Zusammenführung protokolliert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:51:58 +00:00
202cf340f9 companies: Zusammenführungen zum Nachprüfen auflisten
companies:merges --batch= zeigt je Vorgang Überlebenden, Newsroom-URLs auf
beiden Portalen, aufgegangene Firmen und deren alte URLs (301). Der scharfe
Lauf gibt die ersten 30 Vorgänge jetzt ebenfalls als Tabelle aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:10:54 +00:00
1368e2cf07 dev: Live-Trockenlauf der Zusammenführung protokolliert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:58:30 +00:00
8b86b88614 companies: Zusammenführung im Bestand, rückabwickelbar (Roadmap 1a.3)
companies:merge führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom
zusammen — nur über Adressen, die 1a.2 als Dublette oder Namensvariante
klassifiziert hat. Es bleibt die Firma mit den meisten jemals
veröffentlichten Meldungen; Meldungen, Kontakte, Nutzer- und
Zahlungsverknüpfungen wandern per company_id, jede Bewegung steht in
company_merge_moves. companies:unmerge spielt sie rückwärts ab.

77 % der Gruppen sind dieselbe Firma auf beiden Portalen (Altsystem: eine
Firma je Portal). Der Überlebende wird dann `both`; die Meldungen behalten
ihr Portal. Der alte Newsroom-Slug führt per 301 zum Überlebenden.
Slug-Kollisionen auf dem anderen Portal werden übersprungen, nicht
umbenannt. Company::slugExists() kennt jetzt `both`.

Dev-DB: 14 142 Adressen, 16 194 Firmen, 13 297 Meldungen in 2 min;
Rückabwicklung in 1:15 min exakt auf den Stand davor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 08:43:46 +00:00
982bd6eff4 api: Token-Gate erkennt aktive Stripe-Abos
Neukunden, die per Stripe buchen (STR-Kreis), konnten keine API-Tokens
erstellen: ApiAccessEligibilityService prüfte nur user_payment_options
(MAN-Kreis) und die letzte Legacy-Rechnung, nicht das Cashier-Abo.
Jetzt zählt subscribed() wie in User::hasActiveBooking() ebenfalls.

Auslöser: Ticket press@intratec.us (Starter, STR-00008 bezahlt,
„Create tokens" trotzdem gesperrt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:34:38 +00:00
00af8b1d8a companies: Aufrufe als Veröffentlichungsnachweis im Altbestand werten
Das Altsystem hat 2009–2018 rund 126 000 Meldungen auf `rejected` gesetzt,
83 000 davon mit Aufrufen — die waren online und sind pauschal offline
genommen worden. Wer nur `status = published` zählt, stellt pr4you, Blödorn
und Crossover Point mit Hunderten Meldungen neben yahoo@yahoo.com.

Eine Firma gilt jetzt als veröffentlicht, wenn sie eine Meldung mit
`published` ODER `hits > 0` hat. Nie veröffentlicht: 23 → 7 Adressen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:20:57 +00:00
e2f7cf1624 dev: scharfer Klassifizierungslauf auf Live protokolliert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:02:26 +00:00
5e5414ad20 dev: Live-Trockenlauf der Adress-Klassifizierung protokolliert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 09:50:17 +00:00
5e46ff9f68 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
5d0570bee0 companies: Tippfehler und Zusätze im Trockenlauf als dieselbe Firma erkennen
Die Live-Läufe vom 11.09. mit Schwellwert 2, 3 und 4 haben zwei Schwächen der
Klassifizierung gezeigt, die vor 1a.2 behoben sein müssen — dort wird sie
dauerhaft in die Datenbank geschrieben.

Erstens stand JS Research mit 204 Firmen an zweiter Stelle der Agenturliste:
eine einzelne Firma, deren Name siebzehnmal leicht anders geschrieben war
("JS Research", "S Research UG", ...). Ebenso Dr. Müller / Dr. Mueller /
Dr. D. Müller und die Praxis Prof. Dhom mit Datumszusätzen im Namen. Ein
Tippfehler ist kein zweiter Kunde. Normalisierte Namen werden jetzt zu
Ähnlichkeits-Clustern zusammengefasst: geringe Levenshtein-Distanz oder ein
gemeinsamer Anfang aus drei Wörtern gilt unter EINER Firmen-E-Mail als dieselbe
Einheit. Echte Agenturen mit verschiedenen Kundennamen bleiben davon unberührt.

Zweitens galt Welcon Europe (Ltd. & Co. KG neben GmbH & Co. KG) als Agentur,
weil die Rechtsformen erst nach dem Entfernen der Satzzeichen abgeglichen
wurden — "gmbh & co kg" passte dann nie auf "gmbh co kg". Rechtsformen werden
jetzt als einzelne Wörter vom Ende her gestrippt, bis keines mehr passt; das
deckt alle Kombinationen ab, ohne sie einzeln aufzuführen.

Auf dem Bestand steigen die automatisch zusammenführbaren Namensvarianten
damit von 723 auf 1603 Adressen, die Sammeladressen sinken von 1425 auf 1082 —
was jetzt dort steht, sind durchweg Agenturen oder Mehrkunden-Absender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 08:55:04 +00:00
e536e52cdb companies: Trockenlauf zur Newsroom-Konsolidierung (Roadmap 1a.1)
Schreibt nichts. Der Bericht steht vor 1a.2 und 1a.3, weil für die rückwirkende
Zusammenführung im Altbestand das Portal die Verantwortung trägt — dort wird
nicht angelegt, sondern verschmolzen, und das ist nicht folgenlos umkehrbar.

Geschlüsselt wird über die FIRMEN-E-Mail, nicht über die Pressekontaktadresse:
Der Pressekontakt ist der Firma untergeordnet und wechselt, die Firmenidentität
darf nicht an einer Person hängen.

Der Bericht führt fünf Klassen statt der drei Signale aus dem Regelwerk. Zwei
Ergänzungen, die aus den Daten kommen:

Erstens die Namensvariante. "Hoppe Unternehmensberatung" und "Hoppe
Unternehmensberatung GmbH" sind dieselbe Firma; ein Vergleich auf Gleichheit
würde sie trennen. Die Namen werden deshalb normalisiert — Kleinschreibung,
Umlaute aufgelöst, Rechtsform und Satzzeichen entfernt. Das ist der Fall
"gleicher ODER ÄHNLICHER Firmenname" aus dem Regelwerk.

Zweitens, und wichtiger: Adressen, die viele Namen tragen und unter denen NIE
etwas veröffentlicht wurde. Im Bestand sind das 23 Adressen mit 5965 Firmen,
angeführt von yahoo@yahoo.com mit 4376. Dahinter steht keine Firma und keine
Agentur. Würde über sie zusammengeführt, verschmölzen tausende unbeteiligte
Firmen zu einem Newsroom — der teuerste denkbare Fehler dieses Blocks.

Die Namensanzahl allein trennt diese Adressen NICHT von einer großen Agentur:
pr@deutsche-stadtauskunft.ag trägt 133 Namen und hat 134 Firmen mit
veröffentlichten Meldungen. Trennscharf ist erst die Veröffentlichung.

Der Schwellwert für Sammeladressen ist ein Parameter, kein fester Wert — E1
soll an Zahlen entschieden werden, nicht an einem Gefühl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 10:55:15 +00:00
2691a4d988 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
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
81d9c43ab7 dev: Bestandsaufnahme und Umsetzungsprotokoll zum Code-Durchgang 01.09.
Vollständige Prüfung von app/, den Migrationen und dem gesamten bis dahin nicht
committeten Arbeitsstand: gefundene Fehler nach Schwere, die Ursachenanalyse
zur unbrauchbar gewordenen statischen Analyse, und die Voraussetzungen, die die
Roadmap braucht und die noch fehlten.

Das Umsetzungsprotokoll hält je Befund fest, was geändert wurde, welche
Vorschläge abgewandelt oder verworfen wurden und warum — darunter zwei
Falschbefunde meiner eigenen Bestandsaufnahme, die beim Beheben aufgefallen
sind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:08:07 +00:00
8d416693c0 db: Index auf companies.email
companies trug bisher nur (portal, slug), (portal, is_active), (created_at, id)
und einen FULLTEXT über (name, email, slug). Ein FULLTEXT-Index beantwortet
aber kein WHERE email = ? — jede Suche über die Firmen-E-Mail war ein voller
Tabellendurchlauf.

Gebraucht wird der Index für die Newsroom-Konsolidierung: Die schlüsselt
ausdrücklich über die Firmen-E-Mail, nicht über die Pressekontaktadresse, und
die laufende Zuordnung neuer API-Meldungen führt diesen Lookup bei jeder
Einreichung aus. Bewusst nur email statt (portal, email) — die Adresse ist
hochselektiv, die Portal-Einschränkung kostet danach nichts mehr, und derselbe
Index trägt zusätzlich die portalübergreifende Gruppierung des
Trockenlauf-Berichts.

ADD INDEX läuft bei InnoDB auch mit vorhandenem FULLTEXT-Index INPLACE, anders
als ADD COLUMN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:08:06 +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
49182ec9c6 newsletter: Unzustellbare Adressen aus beiden Empfängerlisten nehmen
newsletter:import-bounces liest eine Bounce-Liste und nimmt die betroffenen
Adressen aus jeder künftigen Mailstrecke. Wiederholte Zustellversuche an tote
Adressen kosten den Absenderruf — bei All-Inkl in Form von
554-Spam-Pattern-Ablehnungen — und der trifft zuerst die Magic Links, also
genau den Weg, über den Newsrooms künftig beansprucht werden sollen.

Es gibt zwei getrennte Empfängerlisten, und eine tote Adresse muss aus beiden
verschwinden: `users` als Grundmenge der internen Kampagnen und
`newsletter_subscriptions` als öffentliche Anmeldung auf den Portalen. Die
zweite hängt nicht am Konto — die meisten Einträge dort haben gar keinen User
und wären sonst gar nicht erreicht worden. Sie bekommt unsubscribed_at und
läuft durch den NewsletterSyncService, damit die Abmeldung auch beim externen
Versender ankommt. Als "ohne User-Konto" gilt eine Adresse erst, wenn sie in
keiner der beiden Listen steht.

Verglichen wird ohne Rücksicht auf Groß- und Kleinschreibung: Bounce-Listen
kommen aus dem Mailserver-Log und geben die Adresse so wieder, wie der Absender
sie geschrieben hat.

Die Rückläufer-Datei selbst bleibt lokal — sie enthält echte Adressen und
gehört wie die Alt-API-Logs nicht ins Repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:07:11 +00:00
48d401e68f api: Ziel-Portal einer Einreichung explizit auflösen statt vom Konto erben
Legacy-API-Kunden mit Keys beider Altportale — vor allem connektar — senden
jede Meldung an presseecho.de UND businessportal24.de. Weil die Firma ihr
Portal vom User und die Meldung es von der Firma erbte, landeten beide Kopien
im selben Portal, die zweite mit Slug-Suffix "-2". Verifiziert wurde das über
die Token-Hashes gegen die Legacy-Datenbanken.

Das Portal wird jetzt in fester Reihenfolge aufgelöst: expliziter
portal-Parameter, sonst die aufgerufene Domain, sonst das Portal des
Firmenprofils, zuletzt Portal::Both — genau der Default, den beide Spalten
ohnehin tragen. Die Auflösung ist damit nicht-nullbar; vorher konnte sie null
liefern, was als SQL-Fehler und damit als 500er beim Alt-Client gelandet wäre.
Ein Update über die v1-API verschiebt eine bestehende Meldung ausschließlich
über den expliziten Parameter, nie über die aufgerufene Domain.

press-releases:fix-api-portal-duplicates repariert die Altdaten: Es findet
Paare (gleicher User, Titel, Sprache und Portal, beide per API eingereicht,
kurz nacheinander) und verschiebt die zweite Kopie samt ihrer exklusiven Firma
ins jeweils andere Portal, mit neu vergebenem Slug. Web-Doppel-Submits ohne
Status-Log source=api und Legacy-Sync-Daten mit gesetzter legacy_id werden
geschont. Auf Live ausgeführt und verifiziert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:06:58 +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
d14cb84107 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
8fc18c239b 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
749 changed files with 71435 additions and 12543 deletions

View file

@ -2,10 +2,11 @@
args = [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9",
]
command = "npx"
# Schlüssel über CONTEXT7_API_KEY aus der Umgebung, nicht im Repo.
env_vars = ["CONTEXT7_API_KEY"]
[mcp_servers.playwright]
url = "https://playwright.test/mcp"

View file

@ -22,9 +22,12 @@ PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt.
# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook).
LOG_STACK=single
# `daily`: eine Datei je Tag (laravel-YYYY-MM-DD.log), Rotation nach
# LOG_DAILY_DAYS. Fachliche Meldungen liegen daneben in eigenen Tagesdateien
# je Thema (billing-, ki-, monitoring-, market-, admin-slow-*.log) —
# siehe config/logging.php. `single` nur noch als Notnagel, wächst unbegrenzt.
LOG_STACK=daily
LOG_DAILY_DAYS=14
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
@ -33,6 +36,22 @@ LOG_LEVEL=debug
# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen.
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
# Zweiter Alarmweg per ntfy (Monitoring-Plan M3) — ohne URL/Topic aus.
MONITORING_NTFY_URL=
MONITORING_NTFY_TOPIC=
MONITORING_NTFY_TOKEN=
MONITORING_NTFY_PRIORITY=4
# Tiefer Health-Check /health für den externen Monitor (Bearer oder ?token=).
# Ohne Token gibt es den Endpunkt nicht.
MONITORING_HEALTH_TOKEN=
# Fehler an GlitchTip (selbst gehostet, Sentry-Protokoll; Monitoring-Plan
# 1.1/1.2). Ohne DSN passiert nichts. SENTRY_RELEASE bei jedem Deploy auf
# den Git-Hash setzen, dann ordnet GlitchTip Fehler der Version zu.
SENTRY_LARAVEL_DSN=
SENTRY_BROWSER_DSN=
SENTRY_ENVIRONMENT=
SENTRY_RELEASE=
# ---------------------------------------------------------------------------
# Multi-Domain-Setup (config/domains.php)
@ -69,6 +88,21 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
# Wartezeit auf Tabellen-Sperren in Sekunden (siehe config/database.php).
# Migrationen auf großen Tabellen ggf. mit DB_LOCK_WAIT_TIMEOUT=300 starten.
DB_LOCK_WAIT_TIMEOUT=10
# Aufbewahrung des PM-Ereignis-Logs in Tagen (siehe config/press_releases.php).
# Bestimmt zugleich die Tiefe der Verlaufsanzeige. 395 = 13 Monate.
PRESS_RELEASE_EVENTS_RETENTION_DAYS=395
# Aufbewahrung der Konversions-Ereignisse in Tagen (siehe config/conversion.php).
# Bewusst derselbe Horizont wie oben. 395 = 13 Monate.
CONVERSION_EVENTS_RETENTION_DAYS=395
# Mail-Link-Klick (Stufe 1): derselbe Klick je IP und Meldung zählt höchstens
# einmal in diesem Fenster (Minuten). Audit 29.09.2026, R-08.
CONVERSION_MAIL_CLICK_THROTTLE_MINUTES=30
# ---------------------------------------------------------------------------
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
@ -83,7 +117,7 @@ DB_DATABASE_PRESSEECHO=
DB_USERNAME_PRESSEECHO=
DB_PASSWORD_PRESSEECHO=
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -95,7 +129,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@ -114,6 +148,27 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Bounce-Behandlung (Roadmap 1b.6): Envelope-Sender für Rückläufer — MUSS auf
# der From-Domain liegen (SPF-Ausrichtung), z. B. bounce@pressekonto.com.
# Leer = From-Adresse. Schwellen siehe config/bounces.php.
MAIL_RETURN_PATH=
# IMAP-Zugang zum Bounce-Postfach für `mail:fetch-bounces` (alle 10 Minuten).
# Leer = der Befehl tut nichts.
BOUNCE_IMAP_HOST=
BOUNCE_IMAP_PORT=993
BOUNCE_IMAP_ENCRYPTION=ssl
BOUNCE_IMAP_USERNAME=
BOUNCE_IMAP_PASSWORD=
# Signatur der Bounce-Referenz X-Pk-Ref (Audit 29.09.2026, R-04). Leer = aus
# APP_KEY abgeleitet. Ein Wechsel entwertet nur Signaturen bereits versendeter
# Mails (deren Rückläufer zählen dann weich).
BOUNCE_SIGNING_KEY=
# Bounce OHNE Signatur (hart wie weich): policy = nur protokollieren (sicher,
# Standard); soft = zählt auf den Schwellwert und ist damit fälschbar.
BOUNCE_UNSIGNED_KIND=policy
# Aufbewahrung der Rückläufer-Ereignisse (email_bounces) in Tagen; gelöscht von
# conversion:purge-events. Die Sperrliste bleibt.
BOUNCE_RETENTION_DAYS=395
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@ -186,6 +241,11 @@ NEWSLETTER_SYNC_API_KEY=
# NEWSLETTER_CAMPAIGN_BATCH_SIZE=50
# NEWSLETTER_CAMPAIGN_BATCH_INTERVAL_MINUTES=15
# Kampagnen-Versand freischalten (E9-Schalter, Audit 29.09.2026). Standard
# aus, bis U05–U07 umgesetzt sind; Entwürfe, Test-Mails und Abmeldung gehen
# immer.
NEWSLETTER_SENDING_ENABLED=false
# Google-Login (Laravel Socialite). Die Redirect-URI muss in der Google Cloud
# Console EXAKT der Callback-URL der Portal-Domain entsprechen (Login läuft auf
# dem Portal-Host, nicht zwingend APP_URL). Prod: https://pressekonto.com/...
@ -210,6 +270,10 @@ BILLING_DEPUBLICATION_PRICE_CENTS=3500
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.
UMAMI_ENABLED=false
# Kundenbereich und Zuordnung zur User-ID getrennt (Monitoring-Plan M6) —
# erst nach Ergänzung der Datenschutzerklärung einschalten.
UMAMI_PORTAL_ENABLED=false
UMAMI_IDENTIFY_USERS=false
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
@ -221,3 +285,60 @@ LEGAL_CONTACT_EMAIL=
REPORT_EMAIL_PRESSEKONTO=
REPORT_EMAIL_PRESSEECHO=
REPORT_EMAIL_BUSINESSPORTAL=
# Crawler-Abruf-Protokoll (Roadmap 1c.0): Tag, ab dem Abrufe durch Such- und
# KI-Dienste je Meldung erfasst werden — auf den Deploy-Tag setzen.
CRAWLER_HITS_RECORDED_SINCE=2026-09-18
# Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): beim Deploy auf den Deploy-Tag setzen.
FEED_FETCHES_RECORDED_SINCE=2026-09-21
# Google Search Console, URL Inspection API (Roadmap 1c.4). Läuft nur mit
# Service-Konto (JSON-Schlüssel außerhalb von public/), das in beiden
# Properties als Nutzer eingetragen ist. Kundenansicht bleibt zu (E8).
GSC_ENABLED=false
GSC_SERVICE_ACCOUNT_FILE=
GSC_PROPERTY_PRESSEECHO=sc-domain:presseecho.de
GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.com
GSC_DAILY_BUDGET=300
GSC_CUSTOMER_VISIBLE=false
# Transparenzseite „kostenlos veröffentlichen": Prüfdatum der Portalliste
# (Einträge in config/transparency.php). Leer = Abschnitt erscheint nicht.
TRANSPARENCY_FREE_PORTALS_CHECKED_AT=
# Doppelveröffentlichung (Roadmap Anhang 2, 1c.8): zweite Fassung verweist per
# rel=canonical auf die Erstquelle, Zuordnung je Kategorie in config/canonical.php
# (E9, entschieden 18.09.2026 — Standard an). Aus = jede Fassung bleibt ihr
# eigenes Canonical. ACHTUNG Reihenfolge beim ersten Deploy: migrate →
# press-releases:hash-content → press-releases:assign-canonicals.
CANONICAL_ASSIGNMENT_ENABLED=true
CANONICAL_FALLBACK=first
# Anmeldelinks „Diesen Newsroom verwalten" je Newsroom und Tag (nur versendete
# zählen). Audit 29.09.2026, R-09: 3 → 5.
NEWSROOM_CLAIM_LINKS_PER_DAY=5
# API-Zuordnung neuer Firmen zu einem bestehenden Newsroom (Roadmap 1a.4,
# Audit 29.09.2026, R-01): nur für diese User-IDs (kommagetrennt, z. B. das
# connektar-Konto) — sonst nur, wenn der Nutzer der Firma schon angehört.
# Leer = keine Freischaltung.
NEWSROOM_API_ASSIGNMENT_USER_IDS=
# --- Bildkennzeichnung (Formular v2) und Beweisarchiv (Block F) ---
# IMAGE_RIGHTS_V2: Formular v2 im Bildmanager und in der API; false = Verhalten wie vor der Umstellung.
IMAGE_RIGHTS_V2=false
# Eigener Schlüssel für die IP-Ableitung im Bestätigungsprotokoll (Fallback APP_KEY).
CONFIRMATION_IP_KEY=
# Beweisarchiv: eigener Schlüssel (php artisan archive:key), getrennt sichern — ohne ihn ist das Archiv unlesbar.
ARCHIVE_KEY=
ARCHIVE_PREVIOUS_KEYS=
ARCHIVE_RETENTION_YEARS=3
ARCHIVE_QUARANTINE_DAYS=30
ARCHIVE_SWEEP_PAUSED=false
# Zustimmungsgate vor jeder Einreichung (Block G) — erst mit AGB v0.7 einschalten.
LEGAL_ACCEPTANCE_GATE=false
# IMAGE_PRECHECK: KI-Vorab-Check für Titelbilder (Sichtung auf offensichtliche Probleme, Block K).
# Erst nach AVV mit dem Anbieter und DSE-Absatz einschalten. STAGE 1 = nur Stock-Wasserzeichen, 2 = alle Kategorien.
IMAGE_PRECHECK=false
IMAGE_PRECHECK_STAGE=1
# IMAGE_PRECHECK_MODEL=
# IMAGE_PRECHECK_DOWNGRADE=

View file

@ -11,7 +11,7 @@ on:
- main
permissions:
contents: write
contents: read
jobs:
quality:
@ -33,14 +33,7 @@ jobs:
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# file_pattern: |
# **/*
# !.github/workflows/*
# Prüfen, nicht formatieren (Audit F24): vorher lief Pint im Schreibmodus
# und der Job war grün, obwohl der Commit selbst unformatiert blieb.
- name: Check code style
run: vendor/bin/pint --test

View file

@ -50,5 +50,12 @@ jobs:
- name: Build Assets
run: npm run build
# Vor den Tests: Die statische Analyse ist ohne Gate binnen weniger Wochen
# abgedriftet (Baseline zuletzt 12.05.2026, danach 390 neue Meldungen).
# Neue Meldungen gehören in den Code oder — begründet — in die Baseline,
# nicht ins Rauschen.
- name: Run Static Analysis
run: ./vendor/bin/phpstan analyse --memory-limit=2G --no-progress
- name: Run Tests
run: ./vendor/bin/pest

16
.gitignore vendored
View file

@ -63,5 +63,21 @@ dev/archive/migration-2026/sql/
# Produktivdaten: API-Aktivitaets-Logs der Altportale (Kundendaten + Alt-API-Keys), analog SQL-Dumps
dev/api/
# Bounce-Rückläufer: echte E-Mail-Adressen, gehören nicht ins Repository.
dev/newsletter/
# Rechtstext-Quellen des Users (untracked Arbeitsstand)
docs/legal/
# Schlüsseldateien (Search-Console-Service-Konto), nie ins Repo
config/files/
# Release-Kennung für GlitchTip, entsteht beim Deploy (dev/betrieb/monitoring-plan.md)
/RELEASE
/deploy-files.txt
/deploy-*.tgz
/deploy-*/
# Doku lebt im Obsidian-Vault (iCloud), per Devcontainer nach docs/ eingebunden
# — nicht im Repository (Audit Q-05, entschieden 05.10.2026).
/docs/

View file

@ -11,9 +11,7 @@
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp",
"--api-key",
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
"@upstash/context7-mcp"
]
},
"sequential-thinking": {
@ -27,4 +25,4 @@
"url": "https://playwright.test/mcp"
}
}
}
}

View file

@ -89,6 +89,17 @@ The application uses a sophisticated domain-based theme system that determines s
- **Flux UI**: Premium UI component library for consistent design (Portal only)
- **Multi-Build System**: Separate Vite configurations for different asset bundles
#### Blade: `@php` blocks (IMPORTANT)
Blade extracts `@php … @endphp` blocks **before** anything else — including
comments — with `/(?<!@)@php(.*?)@endphp/s`. An inline `@php($x = …)` that
is followed anywhere later in the same file by an `@endphp` therefore
swallows everything up to that `@endphp` as one raw PHP block and the view
fails to compile ("unexpected end of file, expecting endif") or silently
loses content. This also applies to `@php(` written inside a `{{-- --}}`
comment. Rule: once a file contains `@endphp`, use the block form for every
`@php` in it. `tests/Feature/BladePhpBlockGuardTest.php` enforces this.
### Authentication & Authorization
- **Laravel Fortify**: Handles authentication features
@ -115,6 +126,42 @@ The project uses a **dual-port Vite setup** with separate configurations:
- **Migrations**: Users (with 2FA columns), cache, jobs, personal access tokens
- **Seeders**: `RolesAndPermissionsSeeder` - Run with `php artisan migrate --seed`
#### Schema changes and FULLTEXT indexes (IMPORTANT)
**Any** FULLTEXT index on a table forces MySQL to fall back to
`ALGORITHM=COPY` for `ADD COLUMN` — even a single one, despite the misleading
error text ("supports one FULLTEXT index creation at a time"). The whole table
is rebuilt and **all writes to it block** for the duration.
`press_releases` (>1 GB) is therefore kept free of FULLTEXT indexes. Its
searchable fields are mirrored into **`press_release_search`**, which carries
the indexes instead:
- Kept in sync by `PressReleaseSearchObserver` (all writes, including the
legacy importer, go through Eloquent).
- Repairable with `php artisan press-releases:reindex-search` (`--all` rewrites
every row). Run it after any bulk update done through the query builder —
those do not fire model events.
- **Never add columns to `press_release_search`.** New fields belong on
`press_releases`, where they cost milliseconds.
The remaining FULLTEXT tables (`companies` 70 MB, `users` 25 MB, `contacts`
6 MB, `category_translations` <1 MB) are small enough that a rebuild is a
matter of seconds — they keep their indexes on purpose.
Also relevant:
- `DB_LOCK_WAIT_TIMEOUT` defaults to 10 s so a blocked write frees its worker
instead of piling up. A migration that legitimately has to wait needs
`DB_LOCK_WAIT_TIMEOUT=300 php artisan migrate --force`.
- Never add a write to a large table on a hot request path. The public view
counter deliberately writes only to `press_release_events` and is summed up
by the `press-releases:aggregate-views` scheduler task.
- `php artisan db:online-alter` wraps `pt-online-schema-change` as an optional
escape hatch if one of the small FULLTEXT tables ever grows large. It needs
`percona-toolkit` on the server, which is deliberately **not** part of the
standard setup.
### Testing Framework
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit

View file

@ -1,58 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View file

@ -0,0 +1,208 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseEventType;
use App\Models\AppSetting;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Schreibt die gezählten Aufrufe aus dem Ereignis-Log in den Gesamtzähler
* `press_releases.hits`.
*
* Hintergrund: Früher 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 eine Schemaänderung läuft (FULLTEXT-Indizes
* erzwingen dort einen COPY-Rebuild) — die wartenden Requests haben dann
* PHP-FPM-Worker und MySQL-Verbindungen belegt, bis die ganze Seite stand.
* Der Request schreibt jetzt nur noch das Ereignis in die kleine Log-Tabelle;
* die Summierung passiert hier gebündelt.
*
* Fortschritt läuft über eine Wasserstandsmarke (höchste bereits verarbeitete
* Event-ID) statt über einen Zwischenspeicher: Das Log ist die dauerhafte
* Quelle, dadurch geht bei einem Neustart kein Aufruf verloren und ein
* doppelter Lauf zählt nichts doppelt. Zähler und Marke werden je Durchgang
* gemeinsam committet — sonst wäre nur der Durchgang selbst, nicht der
* einzelne Abbruch darin, wiederanlaufsicher.
*/
class AggregatePressReleaseViews extends Command
{
protected $signature = 'press-releases:aggregate-views
{--chunk=5000 : Ereignisse je Durchgang}
{--budget=480 : Zeitbudget in Sekunden; der Rest wartet auf den nächsten Lauf}';
protected $description = 'Überträgt gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits.';
/**
* Wasserstandsmarke: höchste bereits in `hits` verbuchte Event-ID.
*/
public const WatermarkKey = 'press_release_views.aggregated_through_id';
/**
* Cache-Lock über alle Einstiege (Scheduler UND manueller Aufruf): Zwei
* gleichzeitige Läufe lesen dieselbe Marke und verbuchen denselben Chunk
* doppelt (Audit F19). withoutOverlapping() schützt nur den Scheduler.
*/
public const LockKey = 'press-releases:aggregate-views';
/**
* Der Lock verfällt nach dieser Zeit. Das Zeitbudget (--budget) muss
* darunter liegen, sonst kann ein zweiter Lauf den Lock übernehmen,
* während der erste noch verbucht (Audit U13). Als letzte Schranke wird
* die Marke nur unter Zeilensperre und nur vom erwarteten Stand aus
* fortgeschrieben – ein fremder Stand bricht den Durchgang ab.
*/
public const LockSeconds = 600;
public function handle(): int
{
$lock = Cache::lock(self::LockKey, self::LockSeconds);
if (! $lock->get()) {
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
return self::SUCCESS;
}
try {
return $this->aggregate();
} finally {
$lock->release();
}
}
private function aggregate(): int
{
$chunk = max(100, (int) $this->option('chunk'));
$budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget')));
$startedAt = microtime(true);
// Ungecacht (Audit N21): Die Marke ist Betriebszustand, kein
// Admin-Schalter. Ein veralteter Cachewert ließe jeden Lauf an der
// Prüfung unter der Sperre scheitern — dauerhaft.
$watermark = AppSetting::getValueFresh(self::WatermarkKey);
// Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu
// lesen wäre fatal — `press_releases.hits` enthält den Bestand bereits,
// ein Lauf ab 0 würde jedes Alt-Ereignis ein zweites Mal aufaddieren.
// Das kann im Deploy-Fenster passieren, wenn der Scheduler den neuen
// Task startet, bevor die Migration gelaufen ist. Also setzen wir die
// Marke auf den Ist-Stand und zählen erst ab dem nächsten Aufruf.
if ($watermark === null) {
$latest = (int) PressReleaseEvent::query()->max('id');
AppSetting::setValue(self::WatermarkKey, $latest);
$this->info("Wasserstandsmarke erstmalig auf Event-ID {$latest} gesetzt — der Bestand steckt bereits in `hits`.");
return self::SUCCESS;
}
$watermark = (int) $watermark;
$totalEvents = 0;
$totalReleases = 0;
while (true) {
if (microtime(true) - $startedAt > $budget) {
$this->warn(sprintf('Zeitbudget von %d s erreicht – der Rest folgt im nächsten Lauf.', $budget));
break;
}
// Obergrenze des Durchgangs zuerst festlegen, damit gleichzeitig
// eintreffende Aufrufe die Summe nicht gegen die Marke verschieben.
// Bewusst über die IDs statt über max(): eine Aggregatfunktion
// ignoriert das limit und lieferte die Marke des GESAMTEN Rests.
$ids = PressReleaseEvent::query()
->where('id', '>', $watermark)
->orderBy('id')
->limit($chunk)
->pluck('id');
if ($ids->isEmpty()) {
break;
}
$upperBound = (int) $ids->last();
$counts = PressReleaseEvent::query()
->where('id', '>', $watermark)
->where('id', '<=', $upperBound)
->where('type', PressReleaseEventType::View->value)
->selectRaw('press_release_id, count(*) as views')
->groupBy('press_release_id')
->pluck('views', 'press_release_id');
// Zähler und Marke gehören in EINE Transaktion. Ohne sie wäre der
// Lauf nur zwischen den Durchgängen wiederanlaufsicher: Bricht der
// Prozess mitten im Chunk ab (OOM, Deploy, kill), sind ein Teil der
// Zähler schon erhöht, die Marke steht aber noch auf dem alten
// Stand — der nächste Lauf verbuchte genau diese Aufrufe ein
// zweites Mal.
try {
$this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases);
} catch (RuntimeException $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
$watermark = $upperBound;
}
$this->info("Aufrufe verbucht: {$totalEvents} auf {$totalReleases} Pressemitteilungen (Stand Event-ID {$watermark}).");
return self::SUCCESS;
}
/**
* Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer.
*
* @param Collection<int|string, int|string> $counts
*/
private function bookChunk(Collection $counts, int $watermark, int $upperBound, int &$totalEvents, int &$totalReleases): void
{
DB::transaction(function () use ($counts, $watermark, $upperBound, &$totalEvents, &$totalReleases): void {
// Marke unter Zeilensperre gegen den erwarteten Stand prüfen:
// hat ein anderer Lauf sie inzwischen verschoben (Lock
// verfallen), würde dieser Durchgang dieselben Ereignisse ein
// zweites Mal verbuchen (Audit U13).
$current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first();
if ($current === null || (int) $current->value !== $watermark) {
// Sollte je ein Cache-Leser die Marke veraltet halten: hier
// verwerfen, damit der nächste Lauf sauber neu beginnt.
AppSetting::forgetCached(self::WatermarkKey);
throw new RuntimeException(sprintf(
'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.',
$current === null ? 'null' : (string) $current->value,
$watermark,
));
}
foreach ($counts as $pressReleaseId => $views) {
// toBase(): Eloquents increment() setzt updated_at mit — das
// hat Sitemap-lastmod und dateModified jeder aufgerufenen
// Meldung verfälscht (Umsetzungsaudit 29.09.2026, R-02).
PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey((int) $pressReleaseId)
->toBase()
->increment('hits', (int) $views);
$totalReleases++;
$totalEvents += (int) $views;
}
AppSetting::setValue(self::WatermarkKey, $upperBound);
});
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
use Throwable;
/**
* Beweisarchiv (Block F.3): Tombstone-Stand täglich auf die Archiv-Disk
* exportieren, damit er einen Datenbank-Restore überlebt. Ein
* fehlgeschlagener Export endet mit Fehlerstatus (N10).
*/
class ArchiveExportTombstones extends Command
{
protected $signature = 'archive:export-tombstones';
protected $description = 'Exportiert die gelöschten Beweisarchiv-Einträge als Tombstone-Liste';
public function handle(EvidenceArchive $archive): int
{
try {
$count = $archive->exportTombstones();
} catch (Throwable $exception) {
$this->error('Tombstone-Export fehlgeschlagen: '.$exception->getMessage());
return self::FAILURE;
}
$this->info("Tombstone-Export geschrieben: {$count} Einträge.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Erzeugt einen Schlüssel für ARCHIVE_KEY (32 Byte, base64). Der Schlüssel
* gehört in die Ploi-Umgebung und in eine getrennte Sicherung — ohne ihn
* ist das Archiv unlesbar.
*/
class ArchiveKey extends Command
{
protected $signature = 'archive:key';
protected $description = 'Gibt einen neuen ARCHIVE_KEY aus (nicht automatisch gesetzt)';
public function handle(): int
{
$this->line('ARCHIVE_KEY=base64:'.base64_encode(random_bytes(32)));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): Einträge nach Fristablauf ohne legal_hold
* löschen — Payload weg, Tombstone bleibt. Läuft täglich. Ein Eintrag,
* dessen Datei sich nicht löschen ließ, bleibt aktiv und wird beim
* nächsten Lauf wiederholt; der Lauf endet dann mit Fehlerstatus (N10).
*/
class ArchivePurge extends Command
{
protected $signature = 'archive:purge {--dry-run : Nur zählen}';
protected $description = 'Löscht fällige Beweisarchiv-Einträge (Payload weg, Tombstone bleibt)';
public function handle(EvidenceArchive $archive): int
{
$result = $archive->purgeDue((bool) $this->option('dry-run'));
if ($this->option('dry-run')) {
$this->info('Fällig (Trockenlauf): '.$result['due'].' Archiveinträge.');
return self::SUCCESS;
}
$this->info(sprintf('Gelöscht: %d Archiveinträge (fällig: %d, übersprungen: %d, fehlgeschlagen: %d).', $result['purged'], $result['due'], $result['due'] - $result['purged'] - $result['failed'], $result['failed']));
if ($result['failed'] > 0) {
$this->error('Löschung nicht vollständig — Details im Log; die Einträge bleiben für den nächsten Lauf fällig.');
return self::FAILURE;
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was
* laut Tombstone-Export oder laut Datenbank bereits gelöscht war, erneut
* entfernen — samt Kopien in der Anwendung (A-06). Setzt den Orphan-Sweep
* aus (A-03); danach `archive:reregister`, dann `archive:sweep-orphans
* --resume`. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
*/
class ArchiveReconcileAfterRestore extends Command
{
protected $signature = 'archive:reconcile-after-restore
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}';
protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports und der Datenbank-Tombstones';
public function handle(EvidenceArchive $archive): int
{
$result = $archive->reconcileAfterRestore((bool) $this->option('accept-unsigned'));
$this->line('Tombstone-Export: '.match ($result['export_signature']) {
'valid' => 'Signatur gültig.',
'unsigned' => 'ohne Signatur — auf Anweisung (--accept-unsigned) verwendet.',
'unsigned_refused' => 'ohne Signatur — verworfen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
'invalid' => 'Signatur ungültig — Export verworfen, nur Datenbank-Tombstones abgeglichen.',
default => 'nicht vorhanden — nur Datenbank-Tombstones abgeglichen.',
});
$this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt, %d fehlgeschlagen.', $result['re_tombstoned'], $result['files_removed'], $result['failed']));
if ($result['conflicts'] > 0) {
$this->error(sprintf('%d Export-Zeile(n) passen nicht zur Datenbank (gleiche ID, anderer Payload) — nicht angefasst, bitte im Log prüfen.', $result['conflicts']));
}
$this->warn('Orphan-Sweep ausgesetzt: jetzt archive:reregister ausführen, danach archive:sweep-orphans --resume.');
return $result['failed'] > 0 || $result['conflicts'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-03): nach einem DB-Restore
* die Zeilen für Payloads ohne Zeile aus deren manifest.json wieder
* anlegen — aus `entries/` und aus der Quarantäne. Laut Tombstone-Export
* gelöschte Payloads bleiben außen vor. Legal Hold kennt das Manifest
* nicht: wiederhergestellte Einträge im Admin prüfen.
*/
class ArchiveReregister extends Command
{
protected $signature = 'archive:reregister
{--dry-run : Nur zählen}
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}
{--without-export : Ohne Tombstone-Export registrieren (gelöschte Einträge können zurückkehren)}';
protected $description = 'Registriert Beweisarchiv-Payloads ohne Zeile anhand ihres Manifests neu (nach DB-Restore)';
public function handle(EvidenceArchive $archive): int
{
if (! $archive->isConfigured()) {
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — Payloads lassen sich nicht lesen.');
return self::FAILURE;
}
$result = $archive->reregister(
(bool) $this->option('dry-run'),
(bool) $this->option('accept-unsigned'),
(bool) $this->option('without-export'),
);
if ($result['aborted']) {
$this->error(match ($result['export_signature']) {
'invalid' => 'Tombstone-Export mit ungültiger Signatur — abgebrochen, nichts registriert. Ist ARCHIVE_PREVIOUS_KEYS vollständig?',
'unsigned_refused' => 'Tombstone-Export ohne Signatur — abgebrochen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
default => 'Kein Tombstone-Export — abgebrochen. Ohne ihn können gelöschte Einträge zurückkehren; bewusst mit --without-export.',
});
return self::FAILURE;
}
$this->info(sprintf(
'%d Payload(s) ohne Zeile, %d wieder registriert, %d laut Tombstone-Export gelöscht (übergangen), %d fehlgeschlagen.',
$result['candidates'],
$result['registered'],
$result['skipped_tombstoned'],
$result['failed'],
));
if ($result['registered'] > 0) {
$this->warn('Legal Hold und Kennzahlen stehen nicht im Manifest — wiederhergestellte Einträge im Admin prüfen.');
}
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\ArchivePendingJob;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use Illuminate\Console\Command;
/**
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-01/A-02): offene
* Archivaufträge (archive_pending_jobs) je Ereignistyp nachholen. Läuft
* täglich. Ohne gültigen ARCHIVE_KEY bricht der Lauf ab, ohne einen
* Auftrag anzufassen. Bleibt etwas offen, endet er mit Fehlerstatus, und
* der Systemstatus zeigt die Zahl.
*/
class ArchiveRetryPending extends Command
{
protected $signature = 'archive:retry-pending {--dry-run : Nur zählen}';
protected $description = 'Holt ausstehende Beweisarchiv-Einträge (archive_pending_jobs) nach';
public function handle(PendingArchiveRetry $retry, ArchivePendingQueue $queue, EvidenceArchive $archive): int
{
if ($this->option('dry-run')) {
$status = $queue->status();
$this->info("Offen (Trockenlauf): {$status['jobs']} Archivaufträge, {$status['legacy_markers']} Altmarker ohne Auftrag.");
return self::SUCCESS;
}
if (! $archive->isConfigured()) {
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — nichts nachgeholt, alle Aufträge bleiben offen.');
return self::FAILURE;
}
$queue->adoptLegacyMarkers();
$pending = ArchivePendingJob::query()->count();
$totals = ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
ArchivePendingJob::query()->lazyById(100)->each(function (ArchivePendingJob $job) use ($retry, &$totals): void {
foreach ($retry->retry($job) as $key => $count) {
$totals[$key] += $count;
}
});
$remaining = ArchivePendingJob::query()->count();
$this->info(sprintf(
'Nachgeholt: %d Archiveinträge aus %d Aufträgen; übersprungen: %d; fehlgeschlagen: %d; weiterhin offen: %d.',
$totals['recorded'],
$pending,
$totals['skipped'],
$totals['failed'],
$remaining,
));
return $totals['failed'] > 0 || $totals['skipped'] > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-03): Payload-Dateien ohne
* Archivzeile nach Karenzzeit in die Quarantäne verschieben (nicht löschen
* — nach einem DB-Restore fehlen echten Einträgen die Zeilen), liegen-
* gebliebene .tmp löschen, abgelaufene Quarantäne-Tage entfernen. Läuft
* täglich nach dem Purge. `--pause`/`--resume` schalten den Sweep aus und
* ein; der Restore-Abgleich setzt ihn automatisch aus.
*/
class ArchiveSweepOrphans extends Command
{
protected $signature = 'archive:sweep-orphans
{--hours=24 : Nur Dateien, die älter als X Stunden sind}
{--dry-run : Nur zählen}
{--pause : Sweep aussetzen (z. B. nach einem Restore)}
{--resume : Ausgesetzten Sweep wieder aufnehmen}';
protected $description = 'Verschiebt Beweisarchiv-Payloads ohne zugehörige Archivzeile in die Quarantäne';
public function handle(EvidenceArchive $archive): int
{
if ($this->option('pause')) {
$archive->pauseSweep();
$this->info('Orphan-Sweep ausgesetzt. Fortsetzen mit archive:sweep-orphans --resume (nach archive:reregister).');
return self::SUCCESS;
}
if ($this->option('resume')) {
$archive->resumeSweep();
$this->info('Orphan-Sweep wieder aktiv'.(config('archive.sweep_paused') ? ' — ARCHIVE_SWEEP_PAUSED hält ihn weiterhin an.' : '.'));
return self::SUCCESS;
}
$result = $archive->sweepOrphans((bool) $this->option('dry-run'), max(1, (int) $this->option('hours')));
if ($result['paused']) {
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nichts verschoben. Fortsetzen mit --resume.');
return self::SUCCESS;
}
$this->info(sprintf(
'%s: %d Dateien geprüft, %d verwaist, %d in Quarantäne, %d entfernt; Quarantäne abgelaufen: %d.',
$this->option('dry-run') ? 'Trockenlauf' : 'Bereinigung',
$result['scanned'],
$result['orphans'],
$result['quarantined'],
$result['removed'],
$result['expired'],
));
return ! $this->option('dry-run') && $result['orphans'] > $result['quarantined'] + $result['removed'] ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
use ZipArchive;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-15): Signatur eines
* Anwaltsexports prüfen — neue Exporte (abgeleiteter Schlüssel) und
* Exporte vor der Umstellung (Archivschlüssel) gegen alle bekannten
* Schlüssel, dazu den Hash von payload.zip.
*/
class ArchiveVerifyExport extends Command
{
protected $signature = 'archive:verify-export {path : Pfad zur exportierten ZIP-Datei}';
protected $description = 'Prüft Hash und HMAC-Signatur eines Beweisarchiv-Exports';
public function handle(EvidenceArchive $archive): int
{
$zip = new ZipArchive;
if ($zip->open((string) $this->argument('path')) !== true) {
$this->error('Datei ist kein lesbares ZIP.');
return self::FAILURE;
}
$payload = $zip->getFromName('payload.zip');
$signature = $zip->getFromName('signature.txt');
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true);
$zip->close();
$hash = is_array($metadata) ? (string) ($metadata['payload_sha256'] ?? '') : '';
if (! is_string($payload) || ! is_string($signature) || $hash === '' || ! hash_equals($hash, hash('sha256', $payload))) {
$this->error('Export ungültig: payload.zip passt nicht zum Hash in metadata.json.');
return self::FAILURE;
}
if (! $archive->verifyExportSignature($hash, $signature)) {
$this->error('Signatur ungültig oder mit keinem bekannten Schlüssel prüfbar.');
return self::FAILURE;
}
$this->info('Export gültig: Hash und Signatur stimmen.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\CanonicalAssigner;
use App\Support\WebCaches;
use Illuminate\Console\Command;
/**
* Kanonische Zuordnung über den Bestand (Anhang 2, 1c.8). Neue Paare
* ordnet die Veröffentlichung selbst zu; dieser Lauf holt den Bestand nach
* und stellt nach einer geänderten Kategorie-Zuordnung (E9) alles neu ein.
*/
class AssignCanonicalPressReleases extends Command
{
protected $signature = 'press-releases:assign-canonicals
{--dry-run : Nur zählen, nichts schreiben}
{--verbose-pairs : Jedes Paar ausgeben}';
protected $description = 'Ordnet bei Doppelveröffentlichung je Paar die Erstquelle zu (rel=canonical).';
public function handle(CanonicalAssigner $assigner): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $assigner->enabled() && ! $dryRun) {
$this->warn('Zuordnung ist aus (CANONICAL_ASSIGNMENT_ENABLED). Mit --dry-run lässt sich das Ergebnis trotzdem ansehen.');
return self::SUCCESS;
}
$verbose = (bool) $this->option('verbose-pairs');
$result = $assigner->assignAll($dryRun, function (int $canonicalId, int $duplicateId, bool $changed) use ($verbose): void {
if ($verbose) {
$this->line(sprintf(' %d ← %d%s', $canonicalId, $duplicateId, $changed ? '' : ' (unverändert)'));
}
});
$this->info(sprintf(
'%s%d Paare, %d %s.',
$dryRun ? '[Dry-Run] ' : '',
$result['pairs'],
$result['changed'],
$dryRun ? 'würden geändert' : 'geändert',
));
if ($result['reset'] > 0) {
$this->info(sprintf(
'%s%d veraltete Verweise %s.',
$dryRun ? '[Dry-Run] ' : '',
$result['reset'],
$dryRun ? 'würden zurückgesetzt' : 'zurückgesetzt',
));
}
// rel=canonical steckt in den gecachten Meldungsseiten, die Sitemaps
// (1 h) nennen nur die Erstquelle — nach einer Zuordnung über den
// Bestand sollen beide sofort den neuen Stand zeigen.
if (! $dryRun && ($result['changed'] > 0 || $result['reset'] > 0)) {
WebCaches::forgetPublicPages();
$this->line('Seiten- und Sitemap-Caches geleert.');
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,176 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Bestandsprüfung der `responsible`-Firmenzuordnungen (Audit F01/F10, Plan 1.4).
*
* Erklärbar ist eine Verantwortlichkeit, wenn die Konto-Adresse heute an der
* Firma selbst oder an einem ihrer Pressekontakte hinterlegt ist – genau die
* Grundlage, aus der der Login-Sync sie vergibt. Alles andere wird gelistet:
* manuell vergeben, aus dem Altbestand, oder nach einem Adresswechsel stehen
* geblieben. Der Befehl löscht nichts.
*
* `--classify` setzt bei den erklärbaren Zeilen `granted_via = auto`, damit
* der Login-Sync sie zurücknehmen kann, sobald die Grundlage wegfällt. Nicht
* erklärbare Zeilen bleiben `manual` und damit geschützt – die Entscheidung
* je Zeile trifft ein Mensch anhand der Liste.
*
* Die Klassifizierung ist eine Heuristik (Audit 7.1): eine bewusst manuell
* vergebene Verantwortlichkeit mit zufällig passender Adresse wird ebenfalls
* als auto markiert. Deshalb zeigt --classify vorher die betroffenen Zeilen,
* und --before begrenzt den Lauf auf Zuordnungen aus der Zeit vor dem Deploy
* von `granted_via` – alles Jüngere trägt seine Herkunft bereits.
*/
class AuditCompanyMemberships extends Command
{
protected $signature = 'company-memberships:audit
{--classify : Erklärbare responsible-Zuordnungen als automatisch (auto) markieren}
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}
{--before= : --classify nur für Zuordnungen, die vor diesem Zeitpunkt entstanden sind (Deploy von granted_via); danach ist die Herkunft bereits gespeichert}';
protected $description = 'Listet responsible-Firmenzuordnungen ohne aktuelle E-Mail-Grundlage und markiert erklärbare als automatisch vergeben.';
public function handle(): int
{
$total = DB::table('company_user')->where('role', 'responsible')->count();
$explained = $this->explainedPairs();
$unexplained = $this->responsibleRows()
->reject(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id]))
->values();
$this->info(sprintf('%d responsible-Zuordnungen, davon %d über eine hinterlegte E-Mail erklärbar, %d nicht.', $total, count($explained), $unexplained->count()));
$since = (string) $this->option('since');
$recent = $unexplained->filter(fn (object $row): bool => (string) $row->created_at >= $since);
if ($recent->isNotEmpty()) {
$this->line(sprintf('Nicht erklärbar und seit %s entstanden (%d, max. 50 gezeigt):', $since, $recent->count()));
$this->table(
['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'],
$recent->take(50)->map(fn (object $row): array => [
$row->user_id,
$row->email,
$row->company_id,
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
$row->granted_via,
substr((string) $row->created_at, 0, 16),
])->all(),
);
}
if ($unexplained->isNotEmpty()) {
$file = sprintf('company-memberships-unexplained-%s.csv', now()->format('Y-m-d-His'));
$lines = $unexplained->map(fn (object $row): string => implode(';', [
$row->user_id, $row->email, $row->company_id,
str_replace(';', ',', (string) $row->company_name), $row->granted_via, $row->created_at,
]));
Storage::disk('local')->put($file, "user_id;email;company_id;company_name;granted_via;created_at\n".$lines->implode("\n"));
$this->line(sprintf('Vollständige Liste: storage/app/%s', $file));
}
if (! $this->option('classify')) {
$this->info('Keine Änderung geschrieben. Mit --classify werden die erklärbaren Zeilen als auto markiert.');
return self::SUCCESS;
}
$before = (string) $this->option('before');
$candidates = $this->responsibleRows()
->filter(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id]))
->where('granted_via', 'manual')
->filter(fn (object $row): bool => $before === '' || (string) $row->created_at < $before)
->values();
$this->line(sprintf(
'Umzustufen auf auto: %d Zuordnungen%s (Stichprobe, max. 20):',
$candidates->count(),
$before !== '' ? sprintf(' vor %s', $before) : '',
));
$this->table(
['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'],
$candidates->take(20)->map(fn (object $row): array => [
$row->user_id,
$row->email,
$row->company_id,
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
$row->granted_via,
substr((string) $row->created_at, 0, 16),
])->all(),
);
$marked = 0;
foreach ($candidates->chunk(500) as $chunk) {
foreach ($chunk as $row) {
$marked += DB::table('company_user')
->where('company_id', $row->company_id)
->where('user_id', $row->user_id)
->where('role', 'responsible')
->where('granted_via', 'manual')
->update(['granted_via' => 'auto']);
}
}
$this->info(sprintf('%d Zuordnungen als automatisch (auto) markiert.', $marked));
return self::SUCCESS;
}
/**
* Alle responsible-Zeilen mit Konto-Adresse und Firmenname.
*
* @return Collection<int, \stdClass>
*/
private function responsibleRows(): Collection
{
return DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->where('company_user.role', 'responsible')
->orderBy('company_user.created_at')
->get([
'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at',
'users.email', 'companies.name as company_name',
]);
}
/**
* (company_id, user_id)-Paare, die über Firmen- oder Kontakt-Adresse
* erklärbar sind. Einmaliger Lauf, daher LOWER() beidseitig vertretbar.
*
* @return array<string, array{company_id: int, user_id: int}>
*/
private function explainedPairs(): array
{
$viaCompany = DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->where('company_user.role', 'responsible')
->whereRaw('LOWER(companies.email) = LOWER(users.email)');
$viaContact = DB::table('company_user')
->join('users', 'users.id', '=', 'company_user.user_id')
->join('contacts', function ($join): void {
$join->on('contacts.company_id', '=', 'company_user.company_id')
->whereRaw('LOWER(contacts.email) = LOWER(users.email)')
->whereNull('contacts.deleted_at');
})
->where('company_user.role', 'responsible');
$pairs = [];
foreach ([$viaCompany, $viaContact] as $query) {
foreach ($query->distinct()->get(['company_user.company_id', 'company_user.user_id']) as $row) {
$pairs[$row->company_id.':'.$row->user_id] = ['company_id' => (int) $row->company_id, 'user_id' => (int) $row->user_id];
}
}
return $pairs;
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use App\Enums\ConfirmationChannel;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalText;
use App\Models\PressReleaseImage;
use Illuminate\Console\Command;
/**
* Bildkennzeichnung A.3: Die Bestätigungen des alten Formulars
* (`rights_confirmed_at`, ohne Kennung) bekommen rückwirkend je eine Zeile
* im Protokoll mit Verweis auf `img-confirm-v1`. Ohne IP und User-Agent —
* die wurden damals nicht erfasst. Idempotent.
*/
class BackfillImageRightsConfirmations extends Command
{
protected $signature = 'image-rights:backfill-confirmations {--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Legt für Bilder des alten Formulars rückwirkend einen Protokolleintrag mit img-confirm-v1 an';
public function handle(): int
{
$text = LegalText::findByKey('img-confirm-v1');
if ($text === null) {
$this->error('img-confirm-v1 fehlt — zuerst LegalTextSeeder ausführen.');
return self::FAILURE;
}
$query = PressReleaseImage::query()
->withTrashed()
->whereNotNull('rights_confirmed_at')
->whereDoesntHave('rightsConfirmations')
->with('pressRelease');
$count = $query->count();
$this->info("Bilder mit alter Bestätigung ohne Protokoll: {$count}");
if ($this->option('dry-run') || $count === 0) {
return self::SUCCESS;
}
$written = 0;
$query->chunkById(200, function ($images) use ($text, &$written): void {
foreach ($images as $image) {
ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'company_id' => $image->pressRelease?->company_id,
'user_id' => $image->pressRelease?->user_id,
'channel' => ConfirmationChannel::Form,
'legal_text_id' => $text->id,
'legal_text_key' => $text->key,
'text_snapshot' => $text->body,
'agb_version' => $text->bound_agb_version,
'locale' => $text->locale,
'file_sha256' => null,
'declaration_snapshot' => [
'copyright' => $image->copyright,
'author' => $image->author,
'license_type' => $image->license_type?->value,
'license_detail' => $image->license_detail,
'license_url' => $image->license_url,
'source_url' => $image->source_url,
'ai_declaration' => $image->ai_declaration?->value,
'backfilled' => true,
],
'confirmed_at' => $image->rights_confirmed_at,
'created_at' => now(),
]);
$written++;
}
});
$this->info("Protokolleinträge angelegt: {$written}");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Bildkennzeichnung E.1: `published_ever_at` für den Bestand — jede
* Meldung, die heute veröffentlicht ist, ein `published_at` trägt oder
* laut Status-Log jemals auf `published` stand, war öffentlich. Ihre Bilder
* erben den Zeitpunkt. Idempotent, nur NULL-Zeilen werden gesetzt.
*/
class BackfillPublishedEver extends Command
{
protected $signature = 'press-releases:backfill-published-ever {--dry-run : Nur zählen}';
protected $description = 'Setzt published_ever_at für Meldungen und Bilder, die jemals öffentlich waren';
public function handle(): int
{
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
->whereNull('published_ever_at')
->where(function ($query): void {
$query->whereNotNull('published_at')
->orWhere('status', PressReleaseStatus::Published->value)
->orWhereIn('id', PressReleaseStatusLog::query()->where('to_status', PressReleaseStatus::Published->value)->select('press_release_id'));
});
$count = $candidates->count();
$this->info("Meldungen ohne published_ever_at, die öffentlich waren: {$count}");
if ($this->option('dry-run') || $count === 0) {
return self::SUCCESS;
}
$updated = 0;
$candidates->select(['id', 'published_at', 'created_at'])->chunkById(1000, function ($releases) use (&$updated): void {
foreach ($releases as $release) {
$firstLog = PressReleaseStatusLog::query()
->where('press_release_id', $release->id)
->where('to_status', PressReleaseStatus::Published->value)
->min('created_at');
$at = collect([$release->published_at, $firstLog, $release->created_at])->filter()->min();
DB::table('press_releases')->where('id', $release->id)->update(['published_ever_at' => $at]);
DB::table('press_release_images')->where('press_release_id', $release->id)->whereNull('published_ever_at')
->update(['published_ever_at' => DB::raw('CASE WHEN created_at > '.DB::getPdo()->quote((string) $at).' THEN created_at ELSE '.DB::getPdo()->quote((string) $at).' END')]);
$updated++;
}
});
$this->info("Gesetzt: {$updated} Meldungen samt Bildern.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Services\SearchConsole\IndexStatusChecker;
use App\Services\SearchConsole\SearchConsoleClient;
use Illuminate\Console\Command;
/**
* Indexierungsstatus über die Search Console abfragen (Roadmap 1c.4).
*
* Je Portal ein Tageskontingent, nach Alter gestaffelt auf die jüngsten
* Meldungen verteilt (config search_console.recheck_days). Jede Antwort
* wird als Momentaufnahme gespeichert; der Lauf bewertet nichts und zeigt
* dem Kunden nichts — das entscheidet die Verteilung (1c.5, E8).
*/
class CheckIndexStatus extends Command
{
protected $signature = 'press-releases:check-index-status
{--portal= : Nur dieses Portal (presseecho|businessportal24)}
{--budget= : Abfragen je Portal (Standard: config search_console.daily_budget)}
{--dry-run : Nur auflisten, was geprüft würde}';
protected $description = 'Fragt den Indexierungsstatus veröffentlichter Meldungen bei der Google Search Console ab.';
public function handle(SearchConsoleClient $client, IndexStatusChecker $checker): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $client->isConfigured()) {
$this->warn('Search Console nicht konfiguriert (GSC_ENABLED, GSC_SERVICE_ACCOUNT_FILE) — nichts zu tun.');
return self::SUCCESS;
}
$budget = max(1, (int) ($this->option('budget') ?: config('search_console.daily_budget', 300)));
$portals = $checker->portals();
if ($this->option('portal')) {
$wanted = Portal::tryFrom((string) $this->option('portal'));
$portals = array_values(array_filter($portals, fn (Portal $portal): bool => $portal === $wanted));
}
if ($portals === []) {
$this->warn('Kein Portal mit eingetragener Property.');
return self::SUCCESS;
}
$checked = 0;
$failed = 0;
foreach ($portals as $portal) {
$candidates = $checker->candidates($portal, $budget);
$this->info(sprintf('%s: %d fällige Meldungen (Kontingent %d).', $portal->label(), $candidates->count(), $budget));
foreach ($candidates as $release) {
if ($dryRun) {
$this->line(sprintf(' [%d] %s (veröffentlicht %s)', $release->id, $release->slug, $release->published_at?->format('d.m.Y') ?? '-'));
continue;
}
try {
$check = $checker->check($release, $portal);
$checked++;
$this->line(sprintf(' [%d] %s → %s', $release->id, $release->slug, $check->state->label()));
} catch (\Throwable $e) {
$failed++;
report($e);
$this->error(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage()));
// Quote oder Auth kaputt: der Rest des Laufs würde nur
// weitere Fehler produzieren.
if ($failed >= 5 && $checked === 0) {
$this->error('Abbruch nach fünf Fehlern ohne Treffer.');
return self::FAILURE;
}
}
}
}
if (! $dryRun) {
$this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailDomainCheck;
use App\Rules\PlausibleEmailDomain;
use App\Services\Mail\MailDomainChecker;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
/**
* Prüft die Mail-Domains des Bestands (Kontakterreichbarkeit, Abschnitt 7).
*
* Zwei Aufgaben: einmalig den Bestand aufnehmen (`--months=` grenzt auf
* Firmen mit jüngeren Meldungen ein) und danach täglich die abgelaufenen
* Befunde erneuern. Der Lauf bewertet nichts und sperrt nichts — er hält
* fest, was das DNS sagt.
*/
class CheckMailDomains extends Command
{
protected $signature = 'mail:check-domains
{--months= : Nur Firmen mit Meldungen aus den letzten N Monaten}
{--limit=500 : Höchstens so viele Domains je Lauf}
{--refresh : Auch gültige Befunde neu prüfen}
{--stale : Nur abgelaufene Befunde erneuern (Scheduler)}';
protected $description = 'Prüft die DNS-Zustellwege der hinterlegten E-Mail-Domains.';
public function handle(MailDomainChecker $checker): int
{
$limit = max(1, (int) $this->option('limit'));
$domains = $this->option('stale') ? $this->staleDomains($limit) : $this->domainsFromContacts($limit);
if ($domains->isEmpty()) {
$this->info('Nichts zu prüfen.');
return self::SUCCESS;
}
$counts = [];
$bar = $this->output->createProgressBar($domains->count());
foreach ($domains as $domain) {
$check = $checker->check($domain, force: (bool) $this->option('refresh') || (bool) $this->option('stale'));
$counts[$check->state->value] = ($counts[$check->state->value] ?? 0) + 1;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
foreach ($counts as $state => $count) {
$this->line(sprintf(' %-16s %d', $state, $count));
}
$broken = EmailDomainCheck::query()->broken()->count();
$this->info(sprintf('%d Domains geprüft, %d ohne Zustellweg im Bestand.', $domains->count(), $broken));
return self::SUCCESS;
}
/**
* @return Collection<int, string>
*/
private function staleDomains(int $limit): Collection
{
return EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '<=', now()))
->orderBy('expires_at')
->limit($limit)
->pluck('domain');
}
/**
* @return Collection<int, string>
*/
private function domainsFromContacts(int $limit): Collection
{
$months = $this->option('months') !== null ? max(1, (int) $this->option('months')) : null;
$companies = Company::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months))))
->pluck('email');
$contacts = Contact::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('company', fn ($c) => $c->withoutGlobalScopes()->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months)))))
->pluck('email');
$known = EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('domain')
->flip();
$domains = [];
foreach ($companies->merge($contacts) as $email) {
$domain = PlausibleEmailDomain::domainOf($email);
if ($domain !== null && ! isset($known[$domain])) {
$domains[$domain] = true;
}
}
return collect(array_keys($domains))->take($limit)->values();
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyAddressClass;
use App\Models\CompanyEmailGroup;
use App\Services\Company\CompanyAddressClassifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Persistiert die Klassifizierung der Firmen-E-Mail-Adressen (Roadmap 1a.2).
*
* Schreibt nach `company_email_groups`, worauf die Zusammenführung (1a.3) und
* die laufende Zuordnung neuer API-Meldungen (1a.4) filtern. Wiederholbar:
* Jeder Lauf berechnet alle Gruppen neu, Adressen, die inzwischen nur noch
* eine Firma tragen, verschwinden. Von Hand gesetzte Klassen
* (`manual_classification`) überleben jeden Lauf — sie sind die Stelle, an der
* ein Mensch die Grenzfälle entscheidet, die die Berechnung nicht entscheiden
* kann.
*/
class ClassifyCompanyAddresses extends Command
{
protected $signature = 'companies:classify-addresses
{--threshold= : Schwellwert Sammeladresse (Standard: config newsroom.merge.collection_threshold, E1)}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Klassifiziert alle Firmen-E-Mail-Adressen für die Newsroom-Konsolidierung und speichert das Ergebnis.';
public function handle(CompanyAddressClassifier $classifier): int
{
$threshold = max(2, (int) ($this->option('threshold') ?? config('newsroom.merge.collection_threshold')));
$groups = $classifier->classifyAll($threshold);
$counts = array_fill_keys(array_map(static fn (CompanyAddressClass $c): string => $c->value, CompanyAddressClass::cases()), 0);
foreach ($groups as $group) {
$counts[$group['klasse']->value]++;
}
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Adressen würden klassifiziert (Schwellwert %d).', count($groups), $threshold));
$this->renderCounts($counts);
return self::SUCCESS;
}
$now = now();
$manual = CompanyEmailGroup::query()
->whereNotNull('manual_classification')
->pluck('manual_classification', 'email');
DB::transaction(function () use ($groups, $threshold, $now): void {
$seen = [];
foreach (array_chunk($groups, 500) as $chunk) {
$rows = [];
foreach ($chunk as $group) {
$seen[] = $group['email'];
$rows[] = [
'email' => $group['email'],
'classification' => $group['klasse']->value,
'companies_count' => $group['firmen'],
'distinct_names' => $group['namen_normalisiert'],
'published_companies_count' => $group['firmen_mit_pm'],
'threshold' => $threshold,
'classified_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
// manual_classification steht bewusst NICHT in der Update-Liste:
// Der Lauf berechnet, der Mensch entscheidet — und die
// Entscheidung darf keine Neuberechnung überschreiben.
CompanyEmailGroup::query()->upsert(
$rows,
['email'],
['classification', 'companies_count', 'distinct_names', 'published_companies_count', 'threshold', 'classified_at', 'updated_at'],
);
}
// Adressen, unter denen inzwischen nur noch eine Firma steht,
// brauchen keine Klassifizierung mehr. Von Hand gesetzte bleiben
// trotzdem — die Entscheidung könnte für die nächste Firma unter
// dieser Adresse wieder gelten.
CompanyEmailGroup::query()
->whereNotIn('email', $seen)
->whereNull('manual_classification')
->delete();
});
$this->info(sprintf('%d Adressen klassifiziert (Schwellwert %d).', count($groups), $threshold));
$this->renderCounts($counts);
if ($manual->isNotEmpty()) {
$this->line(sprintf(' %d von Hand gesetzte Klassen unverändert übernommen.', $manual->count()));
}
return self::SUCCESS;
}
/**
* @param array<string, int> $counts
*/
private function renderCounts(array $counts): void
{
$rows = [];
foreach (CompanyAddressClass::cases() as $class) {
$rows[] = [$class->label(), $counts[$class->value], $class->isMergeable() ? 'ja' : 'nein'];
}
$this->table(['Klasse', 'Adressen', 'zusammenführbar'], $rows);
}
}

View file

@ -0,0 +1,282 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Beendet Bestandsschutz-Posten, deren explizites Legacy-Vertragsende
* (`grandfathered_until`, aus valid_until_date importiert) überschritten ist
* (Audit F06, Plan 5.2).
*
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
* läuft der Befehl täglich vor dem Rechnungslauf in zwei Phasen:
*
* Phase 1 (Audit U11): je Kunde in einer Transaktion. Die Kandidaten werden
* unter Zeilensperre mit allen Auswahlbedingungen erneut gelesen und nur mit
* denselben Bedingungen auf `cancelled` gesetzt (`cancelled_at` und
* `grandfathering_expired_at` im selben Schreibzugriff). Eine zwischenzeitliche
* Verlängerung bleibt so erhalten; ein überlappender Lauf trifft null Zeilen
* und schreibt weder Ereignis noch Benachrichtigungsauftrag. Das
* ActivityLog-Ereignis liegt im selben Commit und zählt nur die tatsächlich
* getroffenen Posten.
*
* Phase 2 (Audit U12): alle Posten mit `grandfathering_expired_at` ohne
* `grandfathering_notified_at` werden je Kunde mit genau einer Mail
* nachgeholt und erst danach als benachrichtigt markiert. Scheitert das
* Einplanen, bleibt der Marker leer, der Befehl meldet FAILURE (Scheduler-
* Alarm) und der nächste Lauf holt die Mail nach – ohne den Vertrag erneut
* anzufassen. Gesperrte Adressen werden ohne Mail als erledigt markiert.
*/
class ExpireGrandfathering extends Command
{
protected $signature = 'billing:expire-grandfathering
{--dry-run : Nur anzeigen, welche Posten beendet und welche Kunden benachrichtigt würden}';
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
// E18 als Zusicherung statt nur als Kommentar (OPT-9): vor jeder
// Änderung, damit ein Lauf auf falscher Queue gar nichts anfasst.
if (($violation = $this->queueViolation()) !== null) {
$this->error($violation);
return self::FAILURE;
}
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
if ($candidates->isEmpty() && $pending->isEmpty()) {
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($candidates as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString()));
}
foreach ($pending as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s): Benachrichtigung seit %s offen', $option->id, $option->user_id, $option->grandfathering_expired_at?->toDateTimeString()));
}
return self::SUCCESS;
}
$ended = $this->endExpired($candidates, $activityLog);
[$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions);
$this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified));
if ($failed > 0) {
$this->error(sprintf('%d Benachrichtigung(en) konnten nicht eingeplant werden – der nächste Lauf holt sie nach.', $failed));
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* Phase 1: bedingtes Beenden je Kunde. Liefert die Zahl der tatsächlich
* von diesem Lauf beendeten Posten.
*
* @param Collection<int, UserPaymentOption> $candidates
*/
private function endExpired(Collection $candidates, ActivityLog $activityLog): int
{
$ended = 0;
foreach ($candidates->groupBy('user_id') as $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
$ended += DB::transaction(function () use ($ids, $user, $activityLog): int {
/** @var Collection<int, UserPaymentOption> $current */
$current = $this->expiredQuery()->whereIn('id', $ids)->lockForUpdate()->get();
if ($current->isEmpty()) {
return 0;
}
$now = now();
$affected = $this->expiredQuery()
->whereIn('id', $current->pluck('id')->all())
->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => $now,
'grandfathering_expired_at' => $now,
]);
if ($affected === 0) {
return 0;
}
$latestEnd = $current->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
$activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $affected),
$user,
null,
['action' => 'expire_grandfathering', 'posts' => $affected, 'until' => $latestEnd?->toDateString()],
'system',
);
return $affected;
});
}
return $ended;
}
/**
* Phase 2: je Kunde genau eine Mail für alle offenen Posten.
* Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden].
*
* Idempotent (Audit N14): Je Kunde eine Transaktion — offene Posten unter
* Zeilensperre erneut lesen, den Marker per bedingtem UPDATE beanspruchen
* (0 Zeilen = ein anderer Lauf war schneller, nichts einplanen) und erst
* DANACH die Mail in derselben Transaktion einplanen. Scheitert das
* Einplanen, rollt der Marker mit zurück; der nächste Lauf holt nach.
*
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
* ist damit Teil der Transaktion. handle() sichert das zu (OPT-9,
* queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
* `notified_at` nach Zustellung).
*
* @param Collection<int, UserPaymentOption> $pending
* @return array{0: int, 1: int}
*/
private function notifyPending(Collection $pending, EmailSuppressionService $suppressions): array
{
$notified = 0;
$failed = 0;
foreach ($pending->groupBy('user_id') as $userId => $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
try {
$queued = DB::transaction(function () use ($ids, $user, $suppressions): bool {
/** @var Collection<int, UserPaymentOption> $open */
$open = $this->pendingNotificationQuery()->whereIn('id', $ids)->lockForUpdate()->get();
if ($open->isEmpty()) {
return false;
}
$claimed = $this->pendingNotificationQuery()
->whereIn('id', $open->pluck('id')->all())
->update(['grandfathering_notified_at' => now()]);
if ($claimed === 0) {
return false;
}
if ($user === null || $suppressions->isSuppressed($user->email)) {
// Gesperrte Adresse: ohne Mail als erledigt markiert.
return false;
}
$latestEnd = $open->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
return true;
});
} catch (\Throwable $exception) {
report($exception);
$this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage()));
$failed++;
continue;
}
if ($queued) {
$notified++;
}
}
return [$notified, $failed];
}
/**
* Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in
* derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für
* die Datenbank-Queue auf der Standard-Verbindung — oder für `sync`
* (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft
* und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den
* Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als
* stille Doppel- oder Nicht-Zustellung.
*/
private function queueViolation(): ?string
{
$default = (string) config('queue.default');
if ($default === 'sync') {
return null;
}
if ($default !== 'database') {
return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default);
}
$queueConnection = config('queue.connections.database.connection');
if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) {
return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default'));
}
return null;
}
/**
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
* Sperre und UPDATE identisch (U11).
*
* @return Builder<UserPaymentOption>
*/
private function expiredQuery(): Builder
{
return UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->whereNotNull('grandfathered_until')
->whereDate('grandfathered_until', '<', today());
}
/**
* Beendete Posten, deren Kunde noch nicht informiert wurde (U12).
*
* @return Builder<UserPaymentOption>
*/
private function pendingNotificationQuery(): Builder
{
return UserPaymentOption::query()
->whereNotNull('grandfathering_expired_at')
->whereNull('grandfathering_notified_at');
}
}

View file

@ -0,0 +1,100 @@
<?php
namespace App\Console\Commands;
use App\Services\Mail\BounceMailProcessor;
use App\Support\OperationalAlert;
use Illuminate\Console\Command;
use Throwable;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Folder;
use Webklex\PHPIMAP\Message;
/**
* Holt Rückläufer aus dem Bounce-Postfach (Roadmap 1b.6, Konzept
* `bounce-mails-2026-09-15.md`). Alle zehn Minuten — IDLE braucht es bei
* zwei bis drei Mails am Tag nicht.
*
* Jede Mail wird nach dem Lesen in einen Ordner verschoben: `Processed`,
* `Ignored` oder `Unparsed`. Verschieben statt Flag: Bricht der Lauf
* dazwischen ab, liest der nächste dieselbe Mail erneut — die Sperre bleibt
* dank Message-ID-Unique trotzdem einmalig. Deshalb zählt auch alles in
* der INBOX, nicht nur Ungelesenes: Was ein Mensch dort angesehen oder aus
* `service@` hinübergeschoben hat, trägt das Gelesen-Flag schon und würde
* sonst nie verarbeitet.
*/
class FetchMailBounces extends Command
{
protected $signature = 'mail:fetch-bounces
{--account=bounces : IMAP-Konto aus config/imap.php}
{--limit=200 : Höchstens so viele Mails je Lauf}
{--dry-run : Nur lesen und zählen, nichts sperren, nichts verschieben}';
protected $description = 'Liest Rückläufer aus dem Bounce-Postfach und sperrt unzustellbare Adressen.';
public function handle(ClientManager $clients, BounceMailProcessor $processor): int
{
$account = (string) $this->option('account');
if (blank(config("imap.accounts.{$account}.host")) || blank(config("imap.accounts.{$account}.username"))) {
$this->warn("IMAP-Konto „{$account}“ ist nicht konfiguriert (BOUNCE_IMAP_HOST/USERNAME/PASSWORD) — nichts zu tun.");
return self::SUCCESS;
}
$dryRun = (bool) $this->option('dry-run');
$counts = [BounceMailProcessor::Processed => 0, BounceMailProcessor::Ignored => 0, BounceMailProcessor::Unparsed => 0];
$bounces = 0;
try {
$client = $clients->account($account);
$client->connect();
$inbox = $client->getFolderByName('INBOX');
if ($inbox === null) {
throw new \RuntimeException('INBOX nicht gefunden.');
}
$messages = $inbox->query()->all()->setFetchBody(true)->limit(max(1, (int) $this->option('limit')))->get();
/** @var Message $message */
foreach ($messages as $message) {
$raw = (string) $message->getHeader()?->raw."\r\n\r\n".$message->getRawBody();
$result = $processor->process($raw, $dryRun);
$counts[$result['folder']]++;
$bounces += $result['bounces'];
$this->line(sprintf(' %-10s %s', $result['folder'], $result['subject']));
if (! $dryRun) {
$this->moveTo($client->getFolderByName($result['folder']) ?? $client->createFolder($result['folder']), $message);
}
}
$client->disconnect();
} catch (Throwable $e) {
OperationalAlert::send('Bounce-Postfach konnte nicht gelesen werden', ['account' => $account, 'error' => $e->getMessage()]);
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'%s%d Rückläufer verarbeitet (%d Bounces), %d ignoriert, %d nicht lesbar.',
$dryRun ? '[Dry-Run] ' : '',
$counts[BounceMailProcessor::Processed],
$bounces,
$counts[BounceMailProcessor::Ignored],
$counts[BounceMailProcessor::Unparsed],
));
return self::SUCCESS;
}
private function moveTo(Folder $folder, Message $message): void
{
$message->move($folder->path, expunge: true);
}
}

View file

@ -0,0 +1,208 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Verteilt per API doppelt eingegangene Pressemitteilungen auf die Portale.
*
* Hintergrund (Diagnose 13.08.2026): Legacy-API-Kunden mit Keys beider
* Altportale (z. B. connektar) senden jede PM an presseecho.de UND
* businessportal24.de. Da Firmen- und PM-Portal bisher vom User geerbt
* wurden, landeten beide Kopien im selben Portal — die zweite mit
* Slug-Suffix "-2". Dieses Command findet solche Paare (gleicher User,
* Titel und Sprache, gleiches Portal, kurz nacheinander per API
* eingereicht) und verschiebt die jeweils zweite Kopie samt ihrer
* exklusiven Firma ins jeweils andere Portal; Slugs werden dabei im
* Zielportal neu (ohne Suffix, sofern frei) vergeben.
*
* Web-Doppel-Submits (kein API-Status-Log) und Legacy-Sync-Importe
* (legacy_id gesetzt) werden nicht angefasst.
*
* Verwendung:
* php artisan press-releases:fix-api-portal-duplicates --dry-run
* php artisan press-releases:fix-api-portal-duplicates
* php artisan press-releases:fix-api-portal-duplicates --user=13993 --window=120
*/
class FixApiPortalDuplicates extends Command
{
protected $signature = 'press-releases:fix-api-portal-duplicates
{--since=2026-07-20 : Nur PMs ab diesem Datum (API-Go-Live) berücksichtigen}
{--window=60 : Max. Minuten zwischen den beiden Einreichungen eines Paars}
{--user= : Optional auf eine User-ID beschränken}
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Verschiebt per API doppelt eingereichte PMs (Slug-Suffix "-2") samt Firma ins jeweils andere Portal.';
public function handle(): int
{
$since = (string) $this->option('since');
$windowMinutes = max(1, (int) $this->option('window'));
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
$isDryRun = (bool) $this->option('dry-run');
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$pairs = $this->findDuplicatePairs($since, $windowMinutes, $userId);
if ($pairs->isEmpty()) {
$this->info('Keine API-Duplikat-Paare gefunden.');
return self::SUCCESS;
}
$movedPressReleases = 0;
$movedCompanies = 0;
$rows = [];
foreach ($pairs as [$keep, $move]) {
$target = $move->portal === Portal::Businessportal24
? Portal::Presseecho
: Portal::Businessportal24;
$newSlug = $move->generateUniqueSlug($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
$company = $this->exclusiveCompanyOf($move);
$rows[] = [
$move->id,
mb_strimwidth($move->title, 0, 50, '…'),
"{$move->portal->value} → {$target->value}",
"{$move->slug} → {$newSlug}",
$company ? "#{$company->id} {$company->slug}" : '–',
$keep->id,
];
if (! $isDryRun) {
DB::transaction(function () use ($move, $company, $target, $newSlug, &$movedCompanies): void {
$move->portal = $target;
$move->slug = $newSlug;
$move->saveWithUniqueSlugRetry($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
if ($company) {
$company->portal = $target;
$company->slug = $company->generateUniqueSlug($company->name, [
'portal' => $target->value,
]);
$company->saveWithUniqueSlugRetry($company->name, [
'portal' => $target->value,
]);
$movedCompanies++;
}
});
} elseif ($company) {
$movedCompanies++;
}
$movedPressReleases++;
}
$this->table(
['PM', 'Titel', 'Portal', 'Slug', 'Firma zieht mit', 'Original bleibt'],
$rows,
);
$verb = $isDryRun ? 'würden verschoben werden' : 'verschoben';
$this->info("{$movedPressReleases} Pressemitteilungen und {$movedCompanies} Firmen {$verb}.");
return self::SUCCESS;
}
/**
* Findet Duplikat-Paare: gleicher User, Titel, Sprache und gleiches
* Portal, beide per API eingereicht (Status-Log source=api), innerhalb
* des Zeitfensters nacheinander angelegt. Bei mehr als zwei Treffern
* pro Gruppe (erneute Einreichung derselben PM) werden die Einträge
* chronologisch zu Paaren zusammengefasst.
*
* @return Collection<int, array{0: PressRelease, 1: PressRelease}>
*/
private function findDuplicatePairs(string $since, int $windowMinutes, ?int $userId): Collection
{
$candidates = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->whereNull('legacy_id')
->whereIn('portal', [Portal::Presseecho->value, Portal::Businessportal24->value])
->where('created_at', '>=', $since)
->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId))
->whereExists(function ($query): void {
$query->select(DB::raw(1))
->from('press_release_status_logs')
->whereColumn('press_release_status_logs.press_release_id', 'press_releases.id')
->where('press_release_status_logs.source', 'api');
})
->orderBy('created_at')
->orderBy('id')
->get();
return $candidates
->groupBy(fn (PressRelease $pressRelease): string => implode('|', [
$pressRelease->user_id,
$pressRelease->portal->value,
$pressRelease->language,
$pressRelease->title,
]))
->filter(fn (Collection $group): bool => $group->count() >= 2)
->flatMap(function (Collection $group) use ($windowMinutes): array {
$pairs = [];
$items = $group->values()->all();
for ($i = 0; $i < count($items) - 1; $i++) {
$first = $items[$i];
$second = $items[$i + 1];
if ($first->created_at->diffInMinutes($second->created_at) <= $windowMinutes) {
$pairs[] = [$first, $second];
$i++;
}
}
return $pairs;
})
->values();
}
/**
* Firma der verschobenen PM, sofern sie exklusiv zu dieser PM gehört
* (Legacy-API-Muster: pro Einreichung wird eine frische Firma angelegt).
* Firmen mit weiteren PMs oder aus dem Legacy-Sync bleiben unangetastet.
*/
private function exclusiveCompanyOf(PressRelease $pressRelease): ?Company
{
if ($pressRelease->company_id === null) {
return null;
}
$company = Company::query()
->withoutGlobalScopes([PortalScope::class])
->find($pressRelease->company_id);
if (! $company || $company->legacy_id !== null) {
return null;
}
$hasOtherPressReleases = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->where('company_id', $company->id)
->whereKeyNot($pressRelease->id)
->exists();
return $hasOtherPressReleases ? null : $company;
}
}

View file

@ -11,14 +11,22 @@ use Illuminate\Console\Command;
* Läuft täglich über den Scheduler und stellt für aktive Legacy-
* Zahlungsvereinbarungen (ohne Stripe-Subscription) die fälligen
* Rechnungen aus — wie im Legacy-System. Nicht abrechenbare
* Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt und
* beim nächsten Lauf erneut geprüft.
* Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt, mit
* Sperrvermerk versehen und beim nächsten Lauf erneut geprüft.
*
* Der Lauf arbeitet den gesamten fälligen Bestand in Seiten ab (Audit
* F16): Vorher nahm er die 50 ältesten Fälligkeiten; standen dort 50
* nicht abrechenbare Fälle, kam die 51. nie dran, und ohne Blocker war
* der Durchsatz auf 50 Rechnungen am Tag begrenzt. `--limit` deckelt jetzt
* die Zahl der erstellten Rechnungen, nicht der betrachteten Fälle.
*/
class GenerateManualInvoices extends Command
{
private const PAGE_SIZE = 100;
protected $signature = 'billing:generate-manual-invoices
{--dry-run : Nur anzeigen, was fällig ist}
{--limit=50 : Maximale Anzahl Vereinbarungen pro Lauf}';
{--limit=500 : Maximale Anzahl erstellter Rechnungen pro Lauf}';
protected $description = 'Stellt fällige Rechnungen des manuellen Legacy-Rechnungskreises (MAN-) aus';
@ -27,40 +35,54 @@ class GenerateManualInvoices extends Command
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$due = $service->duePaymentOptions(limit: $limit);
$created = 0;
$skipped = 0;
$seen = [];
if ($due->isEmpty()) {
while ($created < $limit) {
$due = $service->duePaymentOptions(limit: self::PAGE_SIZE, excludeIds: $seen);
if ($due->isEmpty()) {
break;
}
foreach ($due as $option) {
$seen[] = $option->id;
if ($dryRun) {
$this->line(sprintf(
'[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)%s',
$option->id,
$option->user_id,
$option->current_period_end->toDateString(),
$option->billing_hold_reason ? ' – gesperrt: '.$option->billing_hold_reason : '',
));
continue;
}
if ($created >= $limit) {
break;
}
$invoice = $service->invoiceFor($option);
if ($invoice) {
$created++;
$this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id));
} else {
$skipped++;
$this->warn(sprintf('Vereinbarung #%d übersprungen: %s.', $option->id, $option->refresh()->billing_hold_reason ?? 'nicht abrechenbar'));
}
}
}
if ($seen === []) {
$this->info('Keine fälligen Zahlungsvereinbarungen gefunden.');
return self::SUCCESS;
}
$created = 0;
$skipped = 0;
foreach ($due as $option) {
if ($dryRun) {
$this->line(sprintf(
'[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)',
$option->id,
$option->user_id,
$option->current_period_end->toDateString(),
));
continue;
}
$invoice = $service->invoiceFor($option);
if ($invoice) {
$created++;
$this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id));
} else {
$skipped++;
$this->warn(sprintf('Vereinbarung #%d übersprungen (siehe Log).', $option->id));
}
}
if (! $dryRun) {
$this->info(sprintf('%d Rechnung(en) erstellt, %d übersprungen.', $created, $skipped));
}

View file

@ -21,6 +21,13 @@ use Illuminate\Support\Facades\Storage;
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
*
* Zahlungsstatus der jüngsten Rechnung (Einordnung 16.09.2026):
* - `paid` → Bestandsschutz mit der laufenden Periode.
* - `pending`/`reminder` → Bestandsschutz, aber die Rechnung ist offen: die
* Periode wird auf den Leistungsbeginn dieser Rechnung gesetzt, sodass der
* MAN-Kreis sie neu ausstellt (`legacy_conditions.open_legacy_invoice`).
* - `hold` → nie bezahlt, kein Bestandsschutz (übersprungen).
*
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date` —
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
@ -68,12 +75,24 @@ class GrandfatherLegacySubscriptions extends Command
'created' => [],
'updated' => [],
'stale_skipped' => [],
'unpaid_skipped' => [],
'reopened_unpaid' => [],
'immediately_due' => [],
];
foreach ($candidates as $candidate) {
$nextDue = $candidate['next_due_date'];
if ($candidate['source_status'] === LegacyInvoice::STATUS_HOLD) {
$report['unpaid_skipped'][] = $this->describe($candidate);
continue;
}
if ($candidate['open_legacy_invoice'] !== null) {
$report['reopened_unpaid'][] = $this->describe($candidate);
}
if ($nextDue->lessThan($staleBefore)) {
$report['stale_skipped'][] = $this->describe($candidate);
@ -120,6 +139,8 @@ class GrandfatherLegacySubscriptions extends Command
['Neu angelegt', count($report['created'])],
['Aktualisiert (Re-Run)', count($report['updated'])],
['Übersprungen (stale)', count($report['stale_skipped'])],
['Übersprungen (hold, nie bezahlt)', count($report['unpaid_skipped'])],
['Offene Alt-Rechnung → MAN neu', count($report['reopened_unpaid'])],
['Davon sofort fällig', count($report['immediately_due'])],
],
);
@ -197,6 +218,22 @@ class GrandfatherLegacySubscriptions extends Command
? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay()
: $nextDue->copy()->subYear();
$openLegacyInvoice = null;
// Offene Rechnung: Die zuletzt fakturierte Periode ist noch nicht
// bezahlt → Fälligkeit auf deren Leistungsbeginn setzen, damit der
// MAN-Kreis genau diese Periode neu ausstellt.
if (in_array($invoice->status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
$openLegacyInvoice = [
'id' => $invoice->id,
'number' => $invoice->number,
'invoice_date' => $invoice->invoice_date->toDateString(),
'total_cents' => $invoice->total_cents,
];
$nextDue = $periodStart->copy();
$periodStart = $nextDue->copy()->subYear();
}
return [
'user_id' => $invoice->user_id,
'legacy_portal' => $invoice->legacy_portal->value,
@ -207,6 +244,8 @@ class GrandfatherLegacySubscriptions extends Command
'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date']
? Carbon::parse($upo['valid_until_date'])->startOfDay()
: null,
'source_status' => $invoice->status,
'open_legacy_invoice' => $openLegacyInvoice,
'legacy_conditions' => [
'legacy_portal' => $invoice->legacy_portal->value,
'legacy_user_payment_option_id' => (int) $upo['id'],
@ -219,6 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
'source_invoice_number' => $invoice->number,
'source_invoice_date' => $invoice->invoice_date->toDateString(),
...($openLegacyInvoice ? ['open_legacy_invoice' => $openLegacyInvoice] : []),
],
];
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\ContentFingerprint;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
/**
* Prüfsumme für den Bestand nachtragen (Anhang 2, 1c.7).
*
* Neue und geänderte Meldungen bekommen die Summe beim Speichern; dieser
* Lauf holt den Bestand nach. Schreibt in Blöcken über den Query-Builder
* (keine Model-Events, kein Suchindex-Neuaufbau) — je Meldung ein kurzes
* UPDATE auf die große Tabelle, nie ein Statement über alles.
*/
class HashPressReleaseContent extends Command
{
protected $signature = 'press-releases:hash-content
{--all : Auch vorhandene Summen neu berechnen}
{--chunk=500 : Meldungen je Durchgang}';
protected $description = 'Trägt die Inhalts-Prüfsumme (content_hash) für Pressemitteilungen nach.';
public function handle(): int
{
$chunk = max(50, (int) $this->option('chunk'));
$all = (bool) $this->option('all');
$query = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->withTrashed()
->select(['id', 'title', 'text', 'content_hash'])
->when(! $all, fn (Builder $q) => $q->whereNull('content_hash'));
$total = (clone $query)->toBase()->getCountForPagination();
if ($total === 0) {
$this->info('Alle Meldungen tragen eine Prüfsumme — nichts zu tun.');
return self::SUCCESS;
}
$this->info(sprintf('%s: %d Meldungen.', $all ? 'Neu berechnet' : 'Ergänzt', $total));
$bar = $this->output->createProgressBar($total);
$done = 0;
$query->chunkById($chunk, function ($releases) use (&$done, $bar): void {
foreach ($releases as $release) {
$hash = ContentFingerprint::hash($release->title, $release->text);
if ($hash !== $release->content_hash) {
DB::table('press_releases')->where('id', $release->id)->update(['content_hash' => $hash]);
}
}
$done += $releases->count();
$bar->advance($releases->count());
});
$bar->finish();
$this->newLine(2);
$this->info("Fertig: {$done} Meldungen geprüft.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,229 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Enums\EmailSuppressionReason;
use App\Models\NewsletterSubscription;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Newsletter\NewsletterSyncService;
use App\Support\EmailQuery;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Liest eine Datei mit unzustellbaren E-Mail-Adressen (Bounce-Rückläufer,
* z. B. dev/newsletter/mail-address.md) und nimmt die betroffenen Adressen aus
* allen Mailstrecken.
*
* Es gibt zwei getrennte Empfängerlisten, und eine Bounce-Adresse muss aus
* BEIDEN verschwinden:
*
* - `users` — Grundmenge der internen Kampagnen (Admin ▸ Newsletter, siehe
* CampaignRecipients). Hier greift das Opt-out je Benachrichtigungsart.
* - `newsletter_subscriptions` — die öffentliche Anmeldung auf den Portalen,
* mit eigenem `unsubscribed_at` und eigener Synchronisierung nach außen.
* Diese Liste hängt nicht am Konto: Die meisten Einträge haben gar keinen
* User, wurden hier also bislang gar nicht erreicht.
*
* Warum das zählt: Wiederholte Zustellversuche an tote Adressen kosten den
* Absenderruf (bei All-Inkl 554-Spam-Pattern-Ablehnungen), und der trifft
* zuerst die Magic Links — also genau den Weg, über den Newsrooms beansprucht
* werden sollen.
*/
class ImportNewsletterBounces extends Command
{
protected $signature = 'newsletter:import-bounces
{file=dev/newsletter/mail-address.md : Datei mit den Bounce-Adressen}
{--dry-run : Nur anzeigen, was passieren würde}';
protected $description = 'Nimmt unzustellbare Adressen aus einer Bounce-Liste aus Konten und Newsletter-Anmeldungen.';
public function __construct(
private readonly ActivityLog $activityLog,
private readonly NewsletterSyncService $newsletterSync,
private readonly EmailSuppressionService $suppressions,
) {
parent::__construct();
}
public function handle(): int
{
$file = (string) $this->argument('file');
$path = Str::startsWith($file, '/') ? $file : base_path($file);
if (! is_file($path)) {
$this->error("Datei nicht gefunden: {$path}");
return self::FAILURE;
}
$emails = $this->extractEmails((string) file_get_contents($path));
if ($emails === []) {
$this->warn('Keine E-Mail-Adressen in der Datei gefunden.');
return self::SUCCESS;
}
$dryRun = (bool) $this->option('dry-run');
$optedOut = 0;
$alreadyOptedOut = 0;
$unsubscribed = 0;
$alreadyUnsubscribed = 0;
$unknown = [];
foreach ($emails as $email) {
// Seit 1b.6 zuerst die zentrale Sperrliste: Sie wirkt auf JEDE
// Mailstrecke, nicht nur auf Newsletter — und auch auf Adressen,
// die weder Konto noch Anmeldung haben (Pressekontakte, Firmen).
if (! $dryRun) {
$this->suppressions->suppress($email, EmailSuppressionReason::HardBounce, 'import', 'Bounce-Liste '.basename($path));
}
$touchedUsers = $this->suppressUsers($email, $dryRun, $optedOut, $alreadyOptedOut);
$touchedSubscriptions = $this->suppressSubscriptions($email, $dryRun, $unsubscribed, $alreadyUnsubscribed);
// Unbekannt ist eine Adresse erst, wenn sie in KEINER der beiden
// Listen steht — sonst meldete der Lauf Adressen als unbekannt, die
// er soeben aus der öffentlichen Anmeldung genommen hat.
if (! $touchedUsers && ! $touchedSubscriptions) {
$unknown[] = $email;
}
}
$prefix = $dryRun ? '[Dry-Run] ' : '';
$this->info(sprintf('%s%d Adressen in die Sperrliste (email_suppressions) übernommen.', $prefix, count($emails)));
$this->info(sprintf(
'%s%d Adressen gelesen: %d abgemeldet, %d waren bereits abgemeldet, %d ohne User-Konto.',
$prefix,
count($emails),
$optedOut,
$alreadyOptedOut,
count($unknown),
));
$this->info(sprintf(
'%sNewsletter-Anmeldungen: %d abgemeldet, %d waren bereits abgemeldet.',
$prefix,
$unsubscribed,
$alreadyUnsubscribed,
));
foreach ($unknown as $email) {
$this->line("Kein User-Konto: {$email}");
}
return self::SUCCESS;
}
/**
* Opt-out für alle Konten mit dieser Adresse.
*
* @return bool ob die Adresse überhaupt einem Konto zugeordnet ist
*/
private function suppressUsers(string $email, bool $dryRun, int &$optedOut, int &$alreadyOptedOut): bool
{
// Groß-/Kleinschreibung ausdrücklich ignorieren, wie in
// User::findByEmail(). Bounce-Listen kommen aus dem Mailserver-Log und
// geben die Adresse so wieder, wie der Absender sie geschrieben hat.
$users = EmailQuery::whereEmail(User::query(), $email)->get();
foreach ($users as $user) {
if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) {
$alreadyOptedOut++;
continue;
}
$optedOut++;
if ($dryRun) {
$this->line("Würde abmelden: {$user->email} (User #{$user->id})");
continue;
}
$user->optOutOfEmailNotification(EmailNotificationType::Newsletter);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s (%s) wegen unzustellbarer Adresse vom Newsletter abgemeldet.', $user->name, $user->email),
user: $user,
meta: ['type' => EmailNotificationType::Newsletter->value, 'action' => 'unsubscribe', 'method' => 'bounce'],
actorLabel: 'System (Bounce-Import)',
);
}
return $users->isNotEmpty();
}
/**
* Abmeldung der öffentlichen Newsletter-Anmeldung(en) dieser Adresse.
*
* Scope-frei: Die Anmeldung hängt am Portal, der Bounce-Import läuft aber
* auf der Kommandozeile ohne Portal-Kontext und muss alle Portale treffen.
*
* @return bool ob die Adresse überhaupt angemeldet ist
*/
private function suppressSubscriptions(string $email, bool $dryRun, int &$unsubscribed, int &$alreadyUnsubscribed): bool
{
$subscriptions = EmailQuery::whereEmail(NewsletterSubscription::query()->withoutGlobalScopes(), $email)
->get();
foreach ($subscriptions as $subscription) {
if ($subscription->unsubscribed_at !== null) {
$alreadyUnsubscribed++;
continue;
}
$unsubscribed++;
if ($dryRun) {
$this->line("Würde Anmeldung abmelden: {$subscription->email} ({$subscription->portal->value})");
continue;
}
$subscription->update(['unsubscribed_at' => now()]);
// Die Abmeldung muss auch beim externen Versender ankommen, sonst
// stellt der weiter zu und der Absenderruf leidet trotzdem.
$this->newsletterSync->syncSubscription($subscription);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('Newsletter-Anmeldung %s (%s) wegen unzustellbarer Adresse abgemeldet.', $subscription->email, $subscription->portal->value),
user: $subscription->user,
meta: [
'type' => EmailNotificationType::Newsletter->value,
'action' => 'unsubscribe',
'method' => 'bounce',
'newsletter_subscription_id' => $subscription->id,
],
actorLabel: 'System (Bounce-Import)',
);
}
return $subscriptions->isNotEmpty();
}
/**
* Zieht alle E-Mail-Adressen aus dem Datei-Inhalt — die Datei darf
* Überschriften oder Bounce-Text enthalten, gezählt wird pro Adresse.
*
* @return list<string>
*/
private function extractEmails(string $contents): array
{
preg_match_all('/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/', $contents, $matches);
return array_values(array_unique(array_map(
static fn (string $email): string => mb_strtolower($email),
$matches[0],
)));
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\CompanyMerge;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* Zeigt, was eine Zusammenführung (Roadmap 1a.3) getan hat — je Vorgang der
* Überlebende, die aufgegangenen Firmen und die URLs zum Nachprüfen: der
* Newsroom auf beiden Portalen, die alten Slugs, die per 301 weiterführen.
*/
class ListCompanyMerges extends Command
{
protected $signature = 'companies:merges
{--batch= : Nur diesen Lauf}
{--email= : Nur diese Adresse}
{--limit=100 : Höchstens so viele Vorgänge}
{--json : Ausgabe als JSON}';
protected $description = 'Listet Zusammenführungen mit Überlebendem, aufgegangenen Firmen und Prüf-URLs.';
public function handle(): int
{
$merges = CompanyMerge::query()
->when($this->option('batch'), fn (Builder $query, string $batch) => $query->where('batch', $batch))
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', Company::emailKey($email)))
->with([
'survivor',
'moves' => fn ($query) => $query->where('subject', CompanyMergeSubject::Company->value),
])
->orderByDesc('id')
->limit(max(1, (int) $this->option('limit')))
->get();
if ($merges->isEmpty()) {
$this->warn('Keine Vorgänge gefunden.');
return self::SUCCESS;
}
$losers = Company::withTrashed()
->withoutGlobalScopes()
->whereIn('id', $merges->flatMap(fn (CompanyMerge $merge) => $merge->moves->pluck('subject_id'))->all())
->get(['id', 'name', 'slug', 'portal'])
->keyBy('id');
$rows = $merges->map(function (CompanyMerge $merge) use ($losers): array {
$survivor = $merge->survivor;
$gone = $merge->moves->map(fn ($move) => $losers->get($move->subject_id))->filter();
return [
'id' => $merge->getKey(),
'batch' => $merge->batch,
'email' => $merge->email,
'rueckabgewickelt' => $merge->reverted_at?->toDateTimeString(),
'bleibt' => ['id' => $survivor->getKey(), 'name' => $survivor->name, 'slug' => $survivor->slug, 'portal' => $survivor->portal->value],
'newsroom' => $this->newsroomUrls($survivor->portal, $survivor->slug),
'aufgegangen' => $gone->map(fn (Company $loser): array => [
'id' => $loser->getKey(),
'name' => $loser->name,
'slug' => $loser->slug,
'portal' => $loser->portal->value,
// Nur bei abweichendem Slug gibt es eine Weiterleitung —
// bei gleichem findet die URL den Überlebenden direkt.
'alte_url' => $loser->slug !== $survivor->slug ? $this->newsroomUrls($loser->portal, $loser->slug) : [],
])->values()->all(),
'meldungen' => $merge->moved_press_releases_count,
];
});
if ($this->option('json')) {
$this->line((string) json_encode($rows->values()->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->table(
['#', 'Adresse', 'bleibt', 'Newsroom', 'aufgegangen (alte URL → 301)', 'PM'],
$rows->map(fn (array $row): array => [
$row['id'].($row['rueckabgewickelt'] ? ' (zurück)' : ''),
$row['email'],
sprintf('#%d %s', $row['bleibt']['id'], Str::limit($row['bleibt']['name'], 32)),
implode("\n", $row['newsroom']),
implode("\n", array_map(
fn (array $loser): string => sprintf('#%d %s%s', $loser['id'], Str::limit($loser['name'], 28), $loser['alte_url'] ? "\n ".implode("\n ", $loser['alte_url']) : ' (gleicher Slug)'),
$row['aufgegangen'],
)),
$row['meldungen'],
])->all(),
);
return self::SUCCESS;
}
/**
* Newsroom-URL je Portal, auf dem die Firma erreichbar ist.
*
* @return list<string>
*/
private function newsroomUrls(Portal $portal, string $slug): array
{
$portals = $portal === Portal::Both ? ['presseecho', 'businessportal24'] : [$portal->value];
return array_map(
fn (string $key): string => rtrim((string) config("domains.domains.{$key}.url"), '/').'/de/newsroom/'.$slug,
$portals,
);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Mail\MailCircuitBreaker;
use Illuminate\Console\Command;
/**
* Zustand des Mail-Circuit-Breakers anzeigen oder die Pause aufheben.
*/
class MailCircuitCommand extends Command
{
protected $signature = 'mail:circuit {--reset : Pause aufheben und Zähler zurücksetzen}';
protected $description = 'Zeigt, ob der Mailversand angehalten ist (Circuit Breaker), und hebt die Pause auf Wunsch auf.';
public function handle(MailCircuitBreaker $breaker): int
{
if ($this->option('reset')) {
$breaker->reset();
$this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all — laufende Newsletter-Kampagnen: php artisan newsletter:resume {kampagne} --failed');
return self::SUCCESS;
}
$until = $breaker->openUntil();
if ($until !== null) {
$this->warn(sprintf('Versand ANGEHALTEN bis %s.', $until->format('d.m.Y H:i')));
} else {
$this->info(sprintf('Versand läuft. Relay-Ablehnungen im aktuellen Fenster: %d.', $breaker->pendingRejections()));
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,180 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyAddressClass;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* Zusammenführung im Bestand (Roadmap 1a.3).
*
* Läuft über `company_email_groups` und fasst jede zusammenführbare Adresse
* an — Dublette und Namensvariante, eine Handentscheidung übersteuert. Jede
* Adresse ist eine eigene Transaktion mit protokollierten Bewegungen;
* `companies:unmerge` kehrt sie um.
*
* `--limit` ist für den ersten scharfen Lauf gedacht: erst 50 Adressen, dann
* ansehen, dann der Rest. Alle Vorgänge eines Laufs teilen eine Batch-ID.
*/
class MergeCompanies extends Command
{
protected $signature = 'companies:merge
{--dry-run : Nur zeigen, was geschähe}
{--limit= : Höchstens so viele Adressen anfassen}
{--email= : Nur diese eine Adresse}
{--json : Ausgabe als JSON}';
protected $description = 'Führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom zusammen (nur zusammenführbare Adressen).';
public function handle(CompanyMergeService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$batch = (string) Str::uuid();
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$groups = $this->groups();
$stats = ['adressen' => 0, 'zusammengefuehrt' => 0, 'firmen_weg' => 0, 'meldungen' => 0, 'both' => 0, 'uebersprungen' => []];
$plans = [];
$skips = [];
foreach ($groups->lazy() as $group) {
if ($limit !== null && $stats['adressen'] >= $limit) {
break;
}
$result = $service->plan($group);
if ($result instanceof CompanyMergeSkip) {
// Eine Adresse, unter der nichts (mehr) zusammenzuführen ist,
// zählt nicht gegen das Limit — sonst füllt sie beim zweiten
// Lauf die ersten 50 Plätze.
if ($result->reason !== CompanyMergeSkip::NothingToMerge) {
$skips[] = $result;
}
$stats['uebersprungen'][$result->reason] = ($stats['uebersprungen'][$result->reason] ?? 0) + 1;
continue;
}
$stats['adressen']++;
$stats['firmen_weg'] += count($result->losers);
$stats['meldungen'] += $result->pressReleases;
$stats['both'] += $result->targetPortal->value === 'both' && $result->survivor->portal->value !== 'both' ? 1 : 0;
if (count($plans) < 30) {
$plans[] = $result;
}
if ($dryRun) {
continue;
}
$service->merge($result, $batch, flushCaches: false);
$stats['zusammengefuehrt']++;
}
if (! $dryRun && $stats['zusammengefuehrt'] > 0) {
$service->flushCaches();
}
if ($this->option('json')) {
$this->line((string) json_encode([
'dry_run' => $dryRun,
'batch' => $dryRun ? null : $batch,
'statistik' => $stats,
'plaene' => array_map(fn (CompanyMergePlan $plan): array => $this->describe($plan), $plans),
'uebersprungen' => array_map(fn (CompanyMergeSkip $skip): array => ['email' => $skip->email, 'grund' => $skip->reason, 'details' => $skip->details], $skips),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info(sprintf(
'%s%d Adressen, %d Firmen gehen im Überlebenden auf, %d Meldungen wandern, %d Firmen werden portalübergreifend (both).',
$dryRun ? '[Dry-Run] ' : '',
$stats['adressen'],
$stats['firmen_weg'],
$stats['meldungen'],
$stats['both'],
));
if (! $dryRun) {
$this->line(sprintf(' Batch %s', $batch));
$this->line(sprintf(' Prüfen: companies:merges --batch=%s', $batch));
$this->line(sprintf(' Rückabwicklung: companies:unmerge --batch=%s', $batch));
}
foreach ($stats['uebersprungen'] as $reason => $count) {
$this->line(sprintf(' übersprungen (%s): %d', $reason, $count));
}
if ($plans !== []) {
$this->newLine();
$this->table(
['Adresse', 'bleibt', 'Portal', 'geht auf', 'Meldungen'],
array_map(fn (CompanyMergePlan $plan): array => [
$plan->email,
sprintf('#%d %s', $plan->survivor->getKey(), Str::limit($plan->survivor->name, 40)),
$plan->targetPortal->value,
implode(', ', array_map(fn (Company $loser): string => sprintf('#%d %s', $loser->getKey(), Str::limit($loser->name, 30)), $plan->losers)),
$plan->pressReleases,
], $plans),
);
}
if ($skips !== []) {
$this->newLine();
$this->table(
['übersprungen', 'Grund', 'Details'],
array_map(fn (CompanyMergeSkip $skip): array => [$skip->email, $skip->reason, implode(', ', $skip->details)], array_slice($skips, 0, 50)),
);
}
return self::SUCCESS;
}
/**
* Zusammenführbare Adressen: berechnete Klasse, sofern keine
* Handentscheidung dagegen steht — oder Handentscheidung dafür.
*
* @return Builder<CompanyEmailGroup>
*/
private function groups(): Builder
{
$mergeable = array_map(
fn (CompanyAddressClass $class): string => $class->value,
array_filter(CompanyAddressClass::cases(), fn (CompanyAddressClass $class): bool => $class->isMergeable()),
);
return CompanyEmailGroup::query()
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', Company::emailKey($email)))
->where(function (Builder $query) use ($mergeable): void {
$query->whereIn('manual_classification', $mergeable)
->orWhere(fn (Builder $computed) => $computed->whereNull('manual_classification')->whereIn('classification', $mergeable));
})
->orderBy('id');
}
/**
* @return array<string, mixed>
*/
private function describe(CompanyMergePlan $plan): array
{
return [
'email' => $plan->email,
'bleibt' => ['id' => $plan->survivor->getKey(), 'name' => $plan->survivor->name, 'portal' => $plan->survivor->portal->value, 'slug' => $plan->survivor->slug],
'portal_danach' => $plan->targetPortal->value,
'geht_auf' => array_map(fn (Company $loser): array => ['id' => $loser->getKey(), 'name' => $loser->name, 'portal' => $loser->portal->value, 'slug' => $loser->slug], $plan->losers),
'ausgenommen_410' => array_map(fn (Company $company): int => $company->getKey(), $plan->excluded),
'meldungen' => $plan->pressReleases,
];
}
}

View file

@ -342,6 +342,9 @@ class MigrateLegacyMedia extends Command
/**
* @return list<string>
*/
/**
* @return list<'company-logos'|'press-release-images'>
*/
private function selectedTypes(): array
{
$type = (string) $this->option('type');

View file

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Console\Command;
/**
* Minütlicher Herzschlag des Schedulers (Monitoring-Plan 1.4). Bleibt er aus,
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
*/
class MonitoringHeartbeat extends Command
{
protected $signature = 'monitoring:heartbeat';
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
public function handle(HealthProbe $probe): int
{
$probe->recordHeartbeat();
return self::SUCCESS;
}
}

View file

@ -0,0 +1,219 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Schema;
/**
* Führt eine Schemaänderung mit `pt-online-schema-change` aus, statt mit einem
* blockierenden `ALTER TABLE`.
*
* Hintergrund: `press_releases` und `companies` tragen FULLTEXT-Indizes. Die
* zwingen MySQL bei jedem `ADD COLUMN` auf `ALGORITHM=COPY` — ein vollständiger
* Rebuild, währenddessen KEIN Schreibzugriff auf die Tabelle möglich ist (auf
* dem Bestand über eine Stunde). pt-osc legt stattdessen eine Schattentabelle
* an, hält sie per Trigger synchron, kopiert in Blöcken und tauscht am Ende
* atomar um — die Originaltabelle bleibt durchgehend beschreibbar.
*
* Ablauf für eine Migration auf diesen Tabellen:
*
* 1. php artisan db:online-alter press_releases "ADD COLUMN foo TINYINT(1) NULL"
* (ohne --execute nur Trockenlauf)
* 2. … mit --execute wiederholen
* 3. php artisan migrate --force
*
* Schritt 3 verlangt, dass die Migration idempotent geschrieben ist
* (`Schema::hasColumn()`-Prüfung): Die Spalte existiert dann bereits, die
* Migration wird nur noch als ausgeführt vermerkt.
*/
class OnlineSchemaChange extends Command
{
protected $signature = 'db:online-alter
{table : Tabelle, z. B. press_releases}
{alter : ALTER-Klausel OHNE "ALTER TABLE <name>", z. B. "ADD COLUMN gone_at TIMESTAMP NULL"}
{--connection= : Verbindung aus config/database.php (Standard: die voreingestellte)}
{--execute : Änderung wirklich ausführen. Ohne dieses Flag läuft nur ein Trockenlauf.}
{--print-only : Nur den Befehl ausgeben, nichts starten}
{--drop-old-table : Die alte Tabelle nach Erfolg sofort löschen (spart Platte, kein Zurück)}
{--binary=pt-online-schema-change : Pfad zum pt-osc-Skript}';
protected $description = 'Ändert das Schema ohne Schreibsperre via pt-online-schema-change.';
public function handle(): int
{
$connectionName = $this->option('connection') ?: config('database.default');
$config = config("database.connections.{$connectionName}");
if (($config['driver'] ?? null) !== 'mysql') {
$this->error("Verbindung „{$connectionName}“ ist kein MySQL — pt-online-schema-change ist hier wirkungslos.");
return self::FAILURE;
}
$table = (string) $this->argument('table');
try {
if (! Schema::connection($connectionName)->hasTable($table)) {
$this->error("Tabelle „{$table}“ existiert in „{$connectionName}“ nicht.");
return self::FAILURE;
}
if (! $this->hasPrimaryKey($connectionName, $table)) {
$this->error("Tabelle „{$table}“ hat keinen Primärschlüssel — pt-online-schema-change kann sie nicht kopieren.");
return self::FAILURE;
}
} catch (QueryException $e) {
// Lieber eine klare Meldung als ein Stacktrace: Ein Tippfehler in
// der Verbindung darf nicht wie ein Programmfehler aussehen.
$this->error("Verbindung „{$connectionName}“ nicht erreichbar: ".$e->getMessage());
return self::FAILURE;
}
$binary = (string) $this->option('binary');
// Zugangsdaten NICHT als Argument übergeben: die Kommandozeile ist über
// `ps` für jeden Benutzer auf dem Server lesbar. pt-osc liest sie
// stattdessen aus einer nur für uns lesbaren Options-Datei.
$defaultsFile = $this->option('print-only')
? '/tmp/pt-osc-credentials.cnf'
: $this->writeDefaultsFile($config);
try {
$arguments = $this->buildArguments($config, $table, (string) $this->argument('alter'), $defaultsFile);
$this->line('<comment>'.$binary.' '.implode(' ', array_map(escapeshellarg(...), $arguments)).'</comment>');
if ($this->option('print-only')) {
return self::SUCCESS;
}
if (! $this->option('execute')) {
$this->warn('Trockenlauf — die Tabelle wird nicht verändert. Mit --execute wirklich ausführen.');
}
$result = Process::forever()
->run(array_merge([$binary], $arguments), function (string $type, string $output): void {
$this->output->write($output);
});
if ($result->failed()) {
$this->error('pt-online-schema-change ist fehlgeschlagen (Exit-Code '.$result->exitCode().').');
$this->line('Fehlt das Tool? Auf Ubuntu: <info>sudo apt-get install percona-toolkit</info>');
return self::FAILURE;
}
} finally {
if (! $this->option('print-only') && is_file($defaultsFile)) {
@unlink($defaultsFile);
}
}
if ($this->option('execute')) {
$this->info('Fertig. Jetzt „php artisan migrate --force“ nachziehen, damit die Migration vermerkt wird.');
if (! $this->option('drop-old-table')) {
$this->warn(sprintf(
'Die Originaltabelle liegt als `_%s_old` weiter auf der Platte (voller Tabellenumfang). Nach der Kontrolle entfernen: DROP TABLE `_%s_old`;',
$table,
$table,
));
}
}
return self::SUCCESS;
}
/**
* @param array<string, mixed> $config
* @return list<string>
*/
public function buildArguments(array $config, string $table, string $alter, string $defaultsFile): array
{
$dsn = implode(',', [
'F='.$defaultsFile,
'h='.($config['host'] ?? '127.0.0.1'),
'P='.($config['port'] ?? 3306),
'D='.$config['database'],
't='.$table,
]);
return [
'--alter='.$alter,
// Fremdschlüssel: 17 Constraints zeigen auf press_releases und
// companies. `rebuild_constraints` hängt die Kind-Tabellen nach dem
// Tausch sauber um — mit abgeschalteter Prüfung dauert das selbst
// bei einer Million Zeilen unter 200 ms. Die Alternative
// `drop_swap` wäre kurz inkonsistent und wird bewusst vermieden.
'--alter-foreign-keys-method=rebuild_constraints',
// Kopiert wird in Blöcken von je einer halben Sekunde, damit die
// Tabelle zwischendurch für den normalen Betrieb frei ist.
'--chunk-time=0.5',
// Bei Last pausieren statt die Seite mitzureißen.
'--max-load=Threads_running=40',
'--critical-load=Threads_running=100',
// Auch pt-osc selbst darf nicht ewig auf eine Sperre warten.
// `innodb_lock_wait_timeout=1` lässt die Kopier-Blöcke sofort
// zurückweichen, statt den laufenden Betrieb auszubremsen.
'--set-vars=lock_wait_timeout=60,innodb_lock_wait_timeout=1',
// Nach Erfolg bleibt die Originaltabelle als `_<name>_old` liegen —
// das Sicherheitsnetz, falls doch etwas fehlt. Sie belegt aber den
// vollen Platz der Tabelle und muss von Hand entfernt werden.
...($this->option('drop-old-table') ? [] : ['--no-drop-old-table']),
'--print',
'--progress=time,30',
$this->option('execute') ? '--execute' : '--dry-run',
$dsn,
];
}
/**
* @param array<string, mixed> $config
*/
private function writeDefaultsFile(array $config): string
{
$path = tempnam(sys_get_temp_dir(), 'pt-osc-');
chmod($path, 0600);
file_put_contents($path, sprintf(
"[client]\nuser=%s\npassword=%s\n",
self::quoteOptionValue((string) ($config['username'] ?? '')),
self::quoteOptionValue((string) ($config['password'] ?? '')),
));
return $path;
}
/**
* Maskiert einen Wert für eine my.cnf-Optionsdatei.
*
* Ohne Anführungszeichen beginnt ein `#` im Wert einen Kommentar und
* umschließende Leerzeichen fallen weg — ein Passwort mit Raute käme also
* abgeschnitten bei pt-osc an, und der Lauf scheiterte an einem
* Authentifizierungsfehler, dessen Ursache nirgends sichtbar ist.
*/
private static function quoteOptionValue(string $value): string
{
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}
private function hasPrimaryKey(string $connection, string $table): bool
{
return collect(DB::connection($connection)->select('SHOW INDEX FROM `'.$table.'`'))
->contains(fn (object $index): bool => ($index->Key_name ?? null) === 'PRIMARY');
}
}

View file

@ -2,13 +2,26 @@
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Models\ActivityEvent;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\ManualReviewRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use App\Services\PressRelease\StaleClassificationException;
use Carbon\CarbonInterface;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
@ -23,6 +36,31 @@ use Throwable;
*
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
* Mail-Benachrichtigung des Autors.
*
* Inhaltsbindung (Audit F07): Passt die jüngste Klassifikation nicht mehr
* zum aktuellen Text (Admin- oder Korrektur-Bearbeitung nach der Prüfung),
* wird nicht veröffentlicht, sondern eine Prüfung mit Routing eingeplant –
* Grün geht dann zum fälligen Termin live, Rot lehnt ab und benachrichtigt.
* Die Vorprüfung hier ist nur der schnelle Filter; verbindlich prüft
* PressReleaseService::publish() unter der Zeilensperre noch einmal
* (Audit U02) und meldet eine Abweichung als StaleClassificationException –
* die zählt hier ebenfalls als Re-Check, nicht als Fehler; publish() hat die
* Prüfung dann schon eingeplant.
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
*
* Menschliche Entscheidung (Umsetzungsaudit 29.09.2026, K-01): Meldungen mit
* `manual_review_required_at` zieht der Scheduler nie; publish() prüft die
* Marke für automatische Quellen zusätzlich unter der Sperre.
*
* Fachlich blockiert (K-02): Ist das Kontingent aufgebraucht oder wartet ein
* Titelbild noch auf die Bestätigung des Kontoinhabers, bleibt die Meldung in
* `review` — das ist kein Betriebsfehler (sonst alle 5 Minuten Exit 1 und
* Alarm-Mail). Der Lauf endet erfolgreich; die Sperre steht einmal je
* Sperrgrund im Aktivitäts-Verlauf der Meldung (und im Log), bis sich ihr
* Status ändert. Eine eigene Autorenmail gibt es bewusst nicht: für das
* Kontingent existiert keine passende Vorlage, und die Bildbestätigung hat
* der Kunde beim Admin-Upload bereits per Mail angefordert bekommen.
*/
class PublishScheduledPressReleases extends Command
{
@ -38,17 +76,116 @@ class PublishScheduledPressReleases extends Command
*/
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
public function handle(PressReleaseService $service): int
public function handle(PressReleaseService $service, ActivityLog $activityLog): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$now = now();
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
$published = 0;
$rejected = 0;
$failed = 0;
$rechecked = 0;
$blocked = 0;
// Fairer Fortschritt (Umsetzungsaudit 02.10.2026, O06): Fachlich
// blockierte Meldungen bleiben fällig und stünden sonst bei jedem
// Lauf vorn — eine volle Seite davon hielte jede jüngere Meldung auf.
// Deshalb seitenweise per Cursor über (scheduled_at, id); das Limit
// gilt für bearbeitete Meldungen, Blockaden zählen nur auf ein
// Scan-Budget, das den Lauf begrenzt.
$scanBudget = $limit * 10;
$scanned = 0;
$handled = 0;
$cursor = null;
while ($handled < $limit && $scanned < $scanBudget) {
$page = $this->duePage($now, $cursor, min($limit, $scanBudget - $scanned));
if ($page->isEmpty()) {
break;
}
foreach ($page as $pressRelease) {
$scanned++;
$cursor = [$pressRelease->scheduled_at, $pressRelease->id];
$line = sprintf(
' #%d scheduled_at=%s title="%s"',
$pressRelease->id,
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
Str::limit($pressRelease->title, 60),
);
if ($dryRun) {
$this->line($line.' [DRY]');
$handled++;
if ($handled >= $limit) {
break;
}
continue;
}
$outcome = $this->processCandidate($service, $activityLog, $pressRelease, $line);
match ($outcome) {
'published' => $published++,
'rejected' => $rejected++,
'rechecked' => $rechecked++,
'blocked' => $blocked++,
default => $failed++,
};
if ($outcome !== 'blocked') {
$handled++;
}
if ($handled >= $limit) {
break;
}
}
}
if ($scanned === 0) {
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
return self::SUCCESS;
}
if ($dryRun) {
$this->info(sprintf('%d fällige Pressemitteilung(en) gefunden. (Dry-Run)', $scanned));
}
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d blockiert, %d fehlgeschlagen.',
$published,
$rejected,
$rechecked,
$blocked,
$failed,
));
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Nächste Seite fälliger Kandidaten nach dem Cursor (scheduled_at, id).
* Gelb und Grün gehen zum Termin automatisch live (Decision-Update §5.0);
* nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als Fallback in
* der manuellen Queue.
*
* @param array{0: mixed, 1: int}|null $cursor
* @return Collection<int, PressRelease>
*/
private function duePage(CarbonInterface $now, ?array $cursor, int $size): Collection
{
return PressRelease::withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,
@ -56,64 +193,116 @@ class PublishScheduledPressReleases extends Command
])
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now)
->whereNull('manual_review_required_at')
->when($cursor !== null, fn ($query) => $query->where(fn ($query) => $query
->where('scheduled_at', '>', $cursor[0])
->orWhere(fn ($query) => $query->where('scheduled_at', $cursor[0])->where('id', '>', $cursor[1]))))
->orderBy('scheduled_at')
->limit($limit)
->orderBy('id')
->limit(max(1, $size))
->get();
}
if ($candidates->isEmpty()) {
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
/**
* Eine fällige Meldung veröffentlichen oder fachlich einordnen.
*
* @return 'published'|'rejected'|'rechecked'|'blocked'|'failed'
*/
private function processCandidate(PressReleaseService $service, ActivityLog $activityLog, PressRelease $pressRelease, string $line): string
{
if ($this->classificationIsStale($pressRelease)) {
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
return self::SUCCESS;
return 'rechecked';
}
$this->info(sprintf(
'%d fällige Pressemitteilung(en) gefunden.%s',
$candidates->count(),
$dryRun ? ' (Dry-Run)' : '',
));
try {
$service->publish($pressRelease, source: 'scheduler');
$this->line($line.' [OK]');
$published = 0;
$rejected = 0;
$failed = 0;
return 'published';
} catch (BlacklistViolationException $e) {
$this->warn($line.' [REJECT: '.$e->word.']');
foreach ($candidates as $pressRelease) {
$line = sprintf(
' #%d scheduled_at=%s title="%s"',
$pressRelease->id,
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
Str::limit($pressRelease->title, 60),
);
return 'rejected';
} catch (StaleClassificationException) {
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
if ($dryRun) {
$this->line($line.' [DRY]');
return 'rechecked';
} catch (ManualReviewRequiredException) {
$this->warn($line.' [BLOCKIERT: wartet auf manuelle Prüfung]');
continue;
}
return 'blocked';
} catch (QuotaExceededException) {
$this->warn($line.' [BLOCKIERT: Kontingent aufgebraucht]');
$this->recordBlockedOnce($activityLog, $pressRelease, 'quota', 'Kontingent aufgebraucht');
try {
$service->publish($pressRelease, source: 'scheduler');
$published++;
$this->line($line.' [OK]');
} catch (BlacklistViolationException $e) {
$rejected++;
$this->warn($line.' [REJECT: '.$e->word.']');
} catch (Throwable $e) {
$failed++;
$this->error($line.' [FAIL: '.$e->getMessage().']');
report($e);
}
return 'blocked';
} catch (ImageRightsConfirmationRequiredException) {
$this->warn($line.' [BLOCKIERT: Titelbild nicht bestätigt]');
$this->recordBlockedOnce($activityLog, $pressRelease, 'image_rights', 'Titelbild vom Kontoinhaber noch nicht bestätigt');
return 'blocked';
} catch (Throwable $e) {
$this->error($line.' [FAIL: '.$e->getMessage().']');
report($e);
return 'failed';
}
}
/**
* Fachliche Sperre einmal je Sperrgrund festhalten (K-02): Ein Eintrag im
* Aktivitäts-Verlauf der Meldung (für Admins sichtbar) und eine
* Log-Warnung — nicht bei jedem 5-Minuten-Lauf erneut. Ein Statuswechsel
* der Meldung beginnt eine neue Episode.
*/
private function recordBlockedOnce(ActivityLog $activityLog, PressRelease $pressRelease, string $reason, string $label): void
{
$description = sprintf('PM #%d: Termin erreicht, aber nicht veröffentlicht — %s.', $pressRelease->id, $label);
$since = PressReleaseStatusLog::query()
->where('press_release_id', $pressRelease->id)
->max('created_at');
$alreadyRecorded = ActivityEvent::query()
->where('subject_type', $pressRelease->getMorphClass())
->where('subject_id', $pressRelease->id)
->where('description', $description)
->when($since !== null, fn ($query) => $query->where('occurred_at', '>=', $since))
->exists();
if ($alreadyRecorded) {
return;
}
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
$published,
$rejected,
$failed,
));
}
Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]);
return self::SUCCESS;
$activityLog->record(
ActivityType::PressReleaseStatus,
$description,
subject: $pressRelease,
meta: ['publish_blocked' => $reason],
actorLabel: 'scheduler',
);
}
/**
* Jüngste gültige Klassifikation mit Prüfsumme, die nicht zum aktuellen
* Inhalt passt. Altbestand ohne Prüfsumme (vor der Bindung) gilt weiter.
*/
private function classificationIsStale(PressRelease $pressRelease): bool
{
$latestHash = KiAudit::query()
->where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CLASSIFICATION)
->whereNull('discarded_at')
->orderByDesc('id')
->value('content_hash');
return $latestHash !== null
&& $pressRelease->content_hash !== null
&& $latestHash !== $pressRelease->content_hash;
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\ConversionEvent;
use App\Models\EmailBounce;
use Illuminate\Console\Command;
/**
* Begrenzt die Ereignisse der Konversionsleiter auf den Aufbewahrungszeitraum
* (Roadmap 0.7).
*
* Die Frist wurde zusammen mit der Tabelle festgelegt, nicht nachgetragen: 395
* Tage, derselbe Horizont wie beim Aufruf-Ereignis-Log. Anders als dort gibt es
* hier keine Wasserstandsmarke zu beachten — die Ereignisse werden nirgends
* aufsummiert, sie SIND die Auswertung. Wer ältere Zeiträume vergleichen will,
* muss den Report vorher laufen lassen und das Ergebnis festhalten.
*
* Mit aufgeräumt: die Rückläufer-Ereignisse (`email_bounces`, Adresse und
* Diagnosetext) nach `bounces.retention_days` (Umsetzungsaudit 29.09.2026,
* R-10). Die Sperrliste `email_suppressions` bleibt — sie ist der Zustand,
* die Ereignisse sind nur seine Herleitung.
*/
class PurgeConversionEvents extends Command
{
protected $signature = 'conversion:purge-events
{--days= : Ereignisse älter als X Tage löschen (Standard: config conversion.events.retention_days)}
{--chunk=5000 : Zeilen je Durchgang}
{--dry-run : Nur zählen, nichts löschen}';
protected $description = 'Begrenzt die Ereignisse der Konversionsleiter und die Rückläufer-Ereignisse (email_bounces) auf den Aufbewahrungszeitraum.';
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('conversion.events.retention_days'));
if ($days < 1) {
$this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$chunk = max(100, (int) $this->option('chunk'));
$expired = ConversionEvent::query()->where('created_at', '<', $cutoff);
$bounceDays = max(1, (int) config('bounces.retention_days', 395));
$bounceCutoff = now()->subDays($bounceDays);
$expiredBounces = EmailBounce::query()->where('created_at', '<', $bounceCutoff);
if ($this->option('dry-run')) {
$this->info(sprintf(
'[Dry-Run] %d Ereignisse älter als %s.',
(clone $expired)->count(),
$cutoff->format('d.m.Y'),
));
$this->info(sprintf(
'[Dry-Run] %d Rückläufer-Ereignisse älter als %s.',
(clone $expiredBounces)->count(),
$bounceCutoff->format('d.m.Y'),
));
return self::SUCCESS;
}
$deleted = 0;
while (true) {
$removed = (clone $expired)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$deleted += $removed;
}
$this->info(sprintf(
'Konversions-Ereignisse bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage).',
$deleted,
$cutoff->format('d.m.Y'),
$days,
));
$bouncesDeleted = 0;
while (true) {
$removed = (clone $expiredBounces)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$bouncesDeleted += $removed;
}
$this->info(sprintf(
'Rückläufer-Ereignisse bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage, Sperrliste bleibt).',
$bouncesDeleted,
$bounceCutoff->format('d.m.Y'),
$bounceDays,
));
return self::SUCCESS;
}
}

View file

@ -4,11 +4,28 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden,
* und bereinigt die Medien abgelehnter, nie veröffentlichter Einreichungen
* nach derselben Frist (Bildkennzeichnung O1, Umsetzungsaudit N13).
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
*
* Abgelehnte Meldungen behalten ihren Status (Ablehnungsdatensatz bleibt
* als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Was
* möglicherweise veröffentlicht war, bleibt unangetastet — maßgeblich ist
* PressRelease::neverPublished(): `published_ever_at` und der Altbestand
* `rejected` (im Altsystem auch „offline genommen", Umsetzungsaudit
* 29.09.2026 A-05).
*
* Ein defektes Bild bricht den Lauf nicht ab (A-14): der Fehler wird
* gezählt, die Meldung behält ihren Status für den nächsten Lauf, und der
* Befehl endet mit Fehlerstatus.
*/
class PurgeExpiredPressReleaseDrafts extends Command
{
@ -16,7 +33,7 @@ class PurgeExpiredPressReleaseDrafts extends Command
{--days=180 : Entwürfe älter als X Tage archivieren}
{--dry-run : Nur zählen, nichts ändern}';
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
protected $description = 'Archiviert inaktive PM-Entwürfe und bereinigt Medien abgelehnter Einreichungen (älter als X Tage).';
public function handle(): int
{
@ -24,22 +41,80 @@ class PurgeExpiredPressReleaseDrafts extends Command
$isDryRun = $this->option('dry-run');
$cutoff = now()->subDays($days);
$query = PressRelease::withoutGlobalScopes()
$drafts = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Draft->value)
->where('updated_at', '<', $cutoff);
$count = $query->count();
// Abgelehnt und nie veröffentlicht: nur Meldungen, an denen noch
// Bilder hängen — sonst wäre der Lauf jede Woche derselbe Zähler.
$rejected = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Rejected->value)
->neverPublished()
->whereNull('legacy_id')
->where('updated_at', '<', $cutoff)
->whereHas('images');
$draftCount = $drafts->count();
$rejectedCount = $rejected->count();
if ($isDryRun) {
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
$this->warn("[DRY-RUN] {$draftCount} Entwürfe würden archiviert, {$rejectedCount} abgelehnte Einreichungen würden bereinigt. Kein tatsächlicher Vorgang.");
return self::SUCCESS;
}
$query->update(['status' => PressReleaseStatus::Archived->value]);
// Bilder nie veröffentlichter Entwürfe werden mit dem Purge sofort
// und vollständig gelöscht (Bildkennzeichnung E.3, Decision-Update E4).
$remover = app(PressReleaseImageRemover::class);
$failedPressReleaseIds = [];
$imagesRemoved = $this->removeImages((clone $drafts)->neverPublished(), $remover, $failedPressReleaseIds)
+ $this->removeImages(clone $rejected, $remover, $failedPressReleaseIds);
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
// Entwürfe mit gescheiterter Bildlöschung behalten ihren Status,
// sonst fielen sie aus dem nächsten Lauf heraus.
$archived = $drafts->when($failedPressReleaseIds !== [], fn (Builder $query) => $query->whereKeyNot($failedPressReleaseIds))
->update(['status' => PressReleaseStatus::Archived->value]);
$failed = count($failedPressReleaseIds);
$this->info("PM-Entwürfe archiviert: {$archived} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}, Meldungen mit fehlgeschlagener Bildlöschung: {$failed}.");
if ($failed > 0) {
$this->error("Bildlöschung fehlgeschlagen: {$failed} Meldung(en) — Details im Log, sie werden beim nächsten Lauf wiederholt.");
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* @param Builder<PressRelease> $query
* @param list<int> $failedPressReleaseIds
*/
private function removeImages(Builder $query, PressReleaseImageRemover $remover, array &$failedPressReleaseIds): int
{
$removed = 0;
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed, &$failedPressReleaseIds): void {
foreach ($pressReleases as $pressRelease) {
foreach ($pressRelease->images as $image) {
try {
$remover->remove($image);
$removed++;
} catch (Throwable $exception) {
$failedPressReleaseIds[] = (int) $pressRelease->getKey();
Log::error('purge-drafts: Bild konnte nicht gelöscht werden, wird beim nächsten Lauf wiederholt.', [
'press_release_id' => $pressRelease->getKey(),
'press_release_image_id' => $image->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}
});
$failedPressReleaseIds = array_values(array_unique($failedPressReleaseIds));
return $removed;
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Legacy-Bereinigung (Bildkennzeichnung E.5, Entscheidung E-B11): Bilder
* aus dem Alt-Import an Meldungen, die nicht veröffentlicht sind (offline
* genommen, archiviert, Entwurf), werden samt Dateien gelöscht. Sie haben
* weder Nachweis noch Protokoll; eine erneute Einreichung läuft ohne Bild.
* Auf der Dev-Kopie: 10.768 Bilder (Stand 22.09.2026).
*/
class PurgeInactiveLegacyPressReleaseImages extends Command
{
protected $signature = 'press-release-images:purge-inactive-legacy
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
{--chunk=500 : Zeilen je Durchlauf}';
protected $description = 'Löscht Legacy-Bilder an nicht veröffentlichten Meldungen samt Dateien (E-B11)';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$execute = (bool) $this->option('execute');
$inactive = [PressReleaseStatus::Rejected->value, PressReleaseStatus::Archived->value, PressReleaseStatus::Draft->value];
$query = PressReleaseImage::query()
->whereNotNull('press_release_images.legacy_id')
->whereNull('press_release_images.rules_version')
->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->whereIn('press_releases.status', $inactive));
$byStatus = (clone $query)
->join('press_releases', 'press_releases.id', '=', 'press_release_images.press_release_id')
->selectRaw('press_releases.status as status, count(*) as c')
->groupBy('press_releases.status')
->pluck('c', 'status')
->all();
$total = array_sum($byStatus);
$this->info(($execute ? 'Lösche' : 'Trockenlauf:').' Legacy-Bilder an inaktiven Meldungen: '.$total);
foreach ($byStatus as $status => $count) {
$this->line(sprintf(' %-10s %d', $status, $count));
}
if (! $execute || $total === 0) {
return self::SUCCESS;
}
$deleted = 0;
$bytes = 0;
$query->with('pressRelease')->chunkById((int) $this->option('chunk'), function ($images) use (&$deleted, &$bytes): void {
foreach ($images as $image) {
$bytes += $this->sizeOf($image);
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->forceDelete();
$deleted++;
}
});
$this->info(sprintf('Gelöscht: %d Bilder, etwa %s MB freigegeben.', $deleted, number_format($bytes / 1048576, 1, ',', '.')));
return self::SUCCESS;
}
private function sizeOf(PressReleaseImage $image): int
{
$disk = Storage::disk($image->disk);
$size = 0;
foreach (array_filter([$image->path, ...array_values($image->variants ?? [])]) as $path) {
if (is_string($path) && $disk->exists($path)) {
$size += (int) $disk->size($path);
}
}
return $size;
}
}

View file

@ -0,0 +1,170 @@
<?php
namespace App\Console\Commands;
use App\Models\AppSetting;
use App\Models\FeedFetch;
use App\Models\PressReleaseCrawlerHit;
use App\Models\PressReleaseEvent;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Begrenzt das Ereignis-Log der Pressemitteilungen auf einen
* Aufbewahrungszeitraum.
*
* Seit der Aufruf-Zähler nicht mehr im Request auf `press_releases.hits`
* schreibt, ist `press_release_events` die Quelle jedes einzelnen
* Seitenaufrufs — die Tabelle wächst also mit dem Traffic und hatte als
* einzige Log-Tabelle des Systems keine Bereinigung. Für die Gesamtzahl ist
* das Log nach der Aggregation nicht mehr nötig (die steckt in `hits`);
* gebraucht wird es nur noch für den zeitlichen Verlauf in der Absenderansicht.
*
* ZWEI SICHERUNGEN, beide zwingend:
*
* 1. Gelöscht wird ausschließlich UNTERHALB der Wasserstandsmarke von
* `press-releases:aggregate-views`. Alles darüber ist noch nicht in `hits`
* verbucht — würde es hier verschwinden, wären diese Aufrufe dauerhaft
* verloren. Fehlt die Marke, bricht der Lauf ab, statt zu raten.
* 2. Gelöscht wird in Blöcken. Ein einzelnes DELETE über Millionen Zeilen
* hielte die Sperre über die gesamte Laufzeit.
*/
class PurgePressReleaseEvents extends Command
{
protected $signature = 'press-releases:purge-events
{--days= : Ereignisse älter als X Tage löschen (Standard: config press_releases.events.retention_days)}
{--chunk=5000 : Zeilen je Durchgang}
{--dry-run : Nur zählen, nichts löschen}';
protected $description = 'Begrenzt das Ereignis-Log der Pressemitteilungen auf den Aufbewahrungszeitraum.';
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('press_releases.events.retention_days'));
if ($days < 1) {
$this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.');
return self::FAILURE;
}
// Ungecacht (Audit N21): Dieser Leser darf die Marke nie veraltet in
// den Cache zurückschreiben — siehe AggregatePressReleaseViews.
$watermark = AppSetting::getValueFresh(AggregatePressReleaseViews::WatermarkKey);
if ($watermark === null) {
$this->error(
'Wasserstandsmarke des Aufruf-Zählers fehlt — es ist unbekannt, welche Ereignisse '
.'bereits in `hits` verbucht sind. Erst "php artisan press-releases:aggregate-views" laufen lassen.'
);
return self::FAILURE;
}
$watermark = (int) $watermark;
$cutoff = now()->subDays($days);
$chunk = max(100, (int) $this->option('chunk'));
// Grenz-ID statt Filter auf created_at (Umsetzungsaudit 29.09.2026,
// OPT-2): press_release_events hat keinen Index auf created_at, jeder
// Block-DELETE mit Datumsfilter liefe über die ganze Tabelle. Alles
// unterhalb der ersten noch frischen ID ist abgelaufen — exakt, auch
// wenn created_at und ID nicht streng gleich laufen.
$expired = PressReleaseEvent::query()
->where('id', '<=', min($watermark, $this->lastExpiredId(PressReleaseEvent::query(), $cutoff)));
if ($this->option('dry-run')) {
$this->info(sprintf(
'[Dry-Run] %d Ereignisse älter als %s und bereits verbucht (Stand Event-ID %d).',
(clone $expired)->count(),
$cutoff->format('d.m.Y'),
$watermark,
));
// Der echte Lauf räumt auch die beiden Abruf-Protokolle — der
// Probelauf muss denselben Umfang nennen.
$this->info(sprintf(
'[Dry-Run] %d Einträge im Crawler-Abruf-Protokoll und %d im Feed-Abruf-Protokoll älter als %s.',
PressReleaseCrawlerHit::query()->where('id', '<=', $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff))->count(),
FeedFetch::query()->where('created_at', '<', $cutoff)->count(),
$cutoff->format('d.m.Y'),
));
return self::SUCCESS;
}
$deleted = 0;
while (true) {
$removed = (clone $expired)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$deleted += $removed;
}
$this->info(sprintf(
'Ereignis-Log bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage).',
$deleted,
$cutoff->format('d.m.Y'),
$days,
));
// Crawler-Abruf-Protokoll (1c.0): gleiche Aufbewahrung, keine
// Wasserstandsmarke nötig — es wird nirgends aufsummiert.
$crawlerDeleted = 0;
$crawlerUpperId = $this->lastExpiredId(PressReleaseCrawlerHit::query(), $cutoff);
while (true) {
$removed = PressReleaseCrawlerHit::query()->where('id', '<=', $crawlerUpperId)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$crawlerDeleted += $removed;
}
$this->info(sprintf('Crawler-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $crawlerDeleted));
// Abruf-Protokoll der RSS-Feeds (3.1): gleiche Aufbewahrung, ebenfalls
// nirgends aufsummiert.
$feedDeleted = 0;
while (true) {
$removed = FeedFetch::query()->where('created_at', '<', $cutoff)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$feedDeleted += $removed;
}
$this->info(sprintf('Feed-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $feedDeleted));
return self::SUCCESS;
}
/**
* Höchste ID, unterhalb derer alles vor `$cutoff` liegt: eins unter der
* ersten ID mit einem Zeitpunkt ab `$cutoff`. Die Suche läuft den
* Primärschlüssel aufsteigend ab und liest damit nur die abgelaufenen
* Zeilen (im Tagesbetrieb ein Tag), nicht die ganze Tabelle.
*
* @param Builder<covariant Model> $query
*/
private function lastExpiredId(Builder $query, Carbon $cutoff): int
{
$firstFresh = (clone $query)->where('created_at', '>=', $cutoff)->orderBy('id')->value('id');
if ($firstFresh === null) {
return (int) ((clone $query)->max('id') ?? 0);
}
return (int) $firstFresh - 1;
}
}

View file

@ -2,14 +2,20 @@
namespace App\Console\Commands;
use App\Models\ArchivePendingJob;
use App\Models\PressReleaseRedactionSnapshot;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Löscht verschlüsselte Vorher-Snapshots des Meldeverfahrens nach Ablauf
* ihrer Löschfrist (purge_after, gesetzt aus legal.snapshot_retention_days).
* Danach ist eine Schwärzung endgültig — genau so gewollt: Alt-Werte dürfen
* nicht unbegrenzt weiterleben. Läuft täglich via Scheduler.
*
* Ausgenommen sind Meldungen mit offenem Archivauftrag oder Marker
* (Umsetzungsaudit 29.09.2026, A-13): der Nachholer braucht den
* Vorher-Stand noch; der Snapshot fällt im ersten Lauf danach.
*/
class PurgeRedactionSnapshots extends Command
{
@ -21,6 +27,8 @@ class PurgeRedactionSnapshots extends Command
{
$deleted = PressReleaseRedactionSnapshot::query()
->dueForPurge()
->whereNotIn('press_release_id', ArchivePendingJob::query()->select('press_release_id'))
->whereNotIn('press_release_id', DB::table('press_releases')->whereNotNull('archive_pending_at')->select('id'))
->delete();
$this->info("Schwärzungs-Snapshots bereinigt: {$deleted} Einträge gelöscht.");

View file

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use App\Support\ImageCredit;
use Illuminate\Console\Command;
/**
* Bildkennzeichnung H.1, Bestand: OG-Variante mit eingebranntem Nachweis
* für Bilder nachziehen, die noch keine haben (vor Formular v2 hochgeladen
* oder importiert). Quelle ist die größte vorhandene Variante; der Nachweis
* kommt aus der gemeinsamen Auflösung (Foto: … / Quelle: [Firma]).
*/
class RegeneratePressReleaseImageVariants extends Command
{
protected $signature = 'press-release-images:regenerate-variants
{--only=og : Nur diese Variante (derzeit: og)}
{--published : Nur Bilder veröffentlichter Meldungen}
{--force : Auch vorhandene Varianten neu erzeugen}
{--limit=0 : Höchstens so viele Bilder (0 = alle)}
{--execute : Wirklich schreiben (Standard ist ein Trockenlauf)}';
protected $description = 'Erzeugt fehlende OG-Varianten mit eingebranntem Bildnachweis für den Bestand';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
if ($this->option('only') !== 'og') {
$this->error('Derzeit wird nur --only=og unterstützt.');
return self::FAILURE;
}
$query = PressReleaseImage::query()
->with(['pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->with('company')])
->orderBy('id');
if ($this->option('published')) {
$query->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published'));
}
if (! $this->option('force')) {
$query->where(function ($q): void {
$q->whereNull('variants')->orWhereNull('variants->og');
});
}
$total = $query->count();
$limit = (int) $this->option('limit');
$execute = (bool) $this->option('execute');
$this->info(($execute ? 'Erzeuge' : 'Trockenlauf:').' OG-Varianten für '.($limit > 0 ? min($limit, $total) : $total).' von '.$total.' Bildern.');
if (! $execute) {
return self::SUCCESS;
}
$done = 0;
$skipped = 0;
$query->chunkById(200, function ($images) use (&$done, &$skipped, $limit): bool {
foreach ($images as $image) {
if ($limit > 0 && $done >= $limit) {
return false;
}
$caption = $image->pressRelease ? ImageCredit::for($image, $image->pressRelease->company)->line() : null;
$path = $this->imageService->regenerateOgVariant(
$image->disk,
$image->variants,
$image->path,
$image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null,
(bool) $image->no_crop,
$caption,
);
if ($path === null) {
$skipped++;
continue;
}
$image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->saveQuietly();
$done++;
}
return true;
});
$this->info("OG-Varianten erzeugt: {$done}, übersprungen (keine Quelle): {$skipped}.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Console\Commands;
use App\Models\PressRelease;
use App\Models\PressReleaseSearchIndex;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
/**
* Befüllt und repariert die Suchtabelle `press_release_search`.
*
* Zwei Einsätze:
* - Einmalig beim Umstieg, um den Bestand zu indizieren. Läuft blockfrei:
* Es wird nur aus `press_releases` gelesen und in die Suchtabelle
* geschrieben, die zu diesem Zeitpunkt noch niemand abfragt.
* - Später als Reparatur, falls doch einmal an Eloquent vorbei geschrieben
* wurde (Massen-Updates per Query-Builder lösen keine Model-Events aus).
*
* Standardmäßig werden nur fehlende Zeilen ergänzt; `--all` schreibt alles neu.
*
* Zur Blockgröße: Ein Durchgang schreibt `--chunk` vollständige Textkörper in
* EINEM `upsert`. Bei langen Meldungen kann das Statement `max_allowed_packet`
* reißen (Standard 64 MB) — der Lauf bricht dann mit „Packet too large" ab.
* Beim erstmaligen Indizieren des Bestands deshalb im Zweifel kleiner fahren:
* `php artisan press-releases:reindex-search --chunk=100`.
*/
class ReindexPressReleaseSearch extends Command
{
protected $signature = 'press-releases:reindex-search
{--all : Alle Pressemitteilungen neu indizieren statt nur die fehlenden}
{--chunk=500 : Pressemitteilungen je Durchgang}';
protected $description = 'Befüllt bzw. repariert die Volltext-Suchtabelle der Pressemitteilungen.';
public function handle(): int
{
$chunk = max(50, (int) $this->option('chunk'));
$all = (bool) $this->option('all');
$query = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->withTrashed()
->select(['id', 'title', 'subtitle', 'text', 'keywords'])
->when(! $all, fn (Builder $q) => $q->whereNotExists(
fn ($sub) => $sub->select(DB::raw(1))
->from('press_release_search')
->whereColumn('press_release_search.press_release_id', 'press_releases.id'),
));
$total = (clone $query)->toBase()->getCountForPagination();
if ($total === 0) {
$this->info('Suchindex ist vollständig — nichts zu tun.');
return self::SUCCESS;
}
$this->info(($all ? 'Neu indiziert' : 'Ergänzt').": {$total} Pressemitteilungen.");
$bar = $this->output->createProgressBar($total);
$done = 0;
// chunkById statt chunk(): Beim Ergänzen verschwinden die verarbeiteten
// Zeilen aus der Ergebnismenge, ein Offset würde dann Einträge
// überspringen.
$query->chunkById($chunk, function ($releases) use (&$done, $bar): void {
$now = now();
PressReleaseSearchIndex::query()->upsert(
$releases->map(fn (PressRelease $release): array => [
'press_release_id' => $release->getKey(),
'title' => (string) $release->title,
'subtitle' => $release->subtitle,
'text' => (string) $release->text,
'keywords' => $release->keywords,
'indexed_at' => $now,
])->all(),
['press_release_id'],
['title', 'subtitle', 'text', 'keywords', 'indexed_at'],
);
$done += $releases->count();
$bar->advance($releases->count());
});
$bar->finish();
$this->newLine(2);
$this->info("Fertig: {$done} Einträge geschrieben.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Services\Company\CompanyMergeReport;
use Illuminate\Console\Command;
/**
* Trockenlauf zur Newsroom-Konsolidierung (Roadmap 1a.1).
*
* Schreibt nichts. Der Bericht steht vor 1a.2 und 1a.3, weil für die
* rückwirkende Zusammenführung im Altbestand das Portal die Verantwortung
* trägt — dort wird nicht angelegt, sondern verschmolzen, und das ist nicht
* folgenlos umkehrbar.
*/
class ReportCompanyMerge extends Command
{
protected $signature = 'companies:merge-report
{--threshold= : Ab wie vielen abweichenden Firmennamen eine Adresse als Sammeladresse gilt (Standard: config newsroom.merge.collection_threshold, E1)}
{--limit=15 : Zeilen je Liste}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Trockenlauf: welche Firmen fallen bei Zusammenführung über die Firmen-E-Mail zusammen.';
public function handle(CompanyMergeReport $report): int
{
$data = $report->build(
max(2, (int) ($this->option('threshold') ?? config('newsroom.merge.collection_threshold'))),
max(1, (int) $this->option('limit')),
);
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$g = $data['gesamt'];
$this->info('Trockenlauf Newsroom-Konsolidierung — es wird nichts geschrieben.');
$this->newLine();
$this->line(sprintf(' Firmen gesamt: %d', $g['firmen']));
$this->line(sprintf(' Adressen mit mehr als einer Firma: %d (%d Firmen)', $g['adressen_mehrfach'], $g['firmen_mehrfach']));
$this->line(sprintf(' Schwellwert Sammeladresse (E1): ab %d abweichenden Namen', $data['schwellwert']));
$this->newLine();
$rows = [];
$mergeable = 0;
foreach ($data['klassen'] as $class) {
$rows[] = [
$class['klasse'],
$class['adressen'],
$class['firmen'],
$class['faellt_weg'] > 0 ? $class['faellt_weg'] : '–',
$class['firmen_mit_pm'],
$class['beschreibung'],
];
$mergeable += $class['faellt_weg'];
}
$this->table(['Klasse', 'Adressen', 'Firmen', 'fällt weg', 'davon mit PM', 'Bedeutung'], $rows);
$this->line(sprintf(
' Eine automatische Zusammenführung würde %d Firmen zu bestehenden Newsrooms verschmelzen.',
$mergeable,
));
if ($data['nie_veroeffentlicht'] !== []) {
$this->newLine();
$this->warn('Adressen ohne jede Veröffentlichung — von jeder automatischen Aktion ausnehmen:');
$this->table(
['Adresse', 'Namen', 'Firmen', 'davon mit PM'],
array_map(
static fn (array $row): array => [$row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm']],
$data['nie_veroeffentlicht'],
),
);
$this->line(' Viele Namen, nie etwas veröffentlicht. Darunter sind Platzhalter wie yahoo@yahoo.com,');
$this->line(' hinter denen gar keine Firma steht — würde über sie zusammengeführt, verschmölzen');
$this->line(' tausende unbeteiligte Firmen zu einem Newsroom. Es können aber auch inaktive');
$this->line(' Agenturen darunter sein. Deshalb: einzeln ansehen, nichts automatisch tun.');
}
if ($data['sammeladressen'] !== []) {
$this->newLine();
$this->line('<comment>Sammeladressen — Kandidaten für das Agenturmodell (3.3):</comment>');
$this->table(
['Adresse', 'Namen', 'Firmen', 'davon mit PM', 'Beispiele'],
array_map(
static fn (array $row): array => [
$row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm'], $row['beispiele'],
],
$data['sammeladressen'],
),
);
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Console\Commands;
use App\Services\Conversion\ConversionFunnelReport;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Trichter je Herkunft und Zeitraum (Roadmap 0.6).
*
* Die Leiter beantwortet nicht „wie viele konvertieren", sondern „an welcher
* Stufe bricht es ab" — deshalb steht neben jeder Stufe, was ein Abbruch dort
* bedeutet.
*/
class ReportConversionFunnel extends Command
{
protected $signature = 'conversion:funnel
{--months=3 : Zeitraum in Monaten rückwärts}
{--from= : Startdatum, überschreibt --months}
{--to= : Enddatum (Standard: jetzt)}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Konversionstrichter je Herkunft: Firmen je Stufe und Übergangsquoten.';
public function handle(ConversionFunnelReport $report): int
{
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
$from = $this->option('from') !== null
? Carbon::parse((string) $this->option('from'))
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
$data = $report->build($from, $to);
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info("Konversionstrichter — {$data['from']} bis {$data['to']}");
if ($data['sources'] === []) {
$this->newLine();
$this->warn('Keine Ereignisse im Zeitraum. Herkunftsdaten lassen sich nicht rückwirkend erzeugen — '
.'wenn hier dauerhaft nichts steht, greift eine Schreibstelle nicht.');
return self::SUCCESS;
}
foreach ($data['sources'] as $source) {
$this->newLine();
$this->line("<comment>{$source['label']}</comment>");
$rows = [];
foreach ($source['steps'] as $step) {
$rows[] = [
$step['step'],
$step['label'],
$step['firmen'],
$step['uebergang'] === null ? '–' : $step['uebergang'].' %',
$step['ohne_firma'] > 0 ? $step['ohne_firma'] : '',
$step['diagnosis'],
];
}
$this->table(
['#', 'Stufe', 'Firmen', 'Übergang', 'ohne Firma', 'Ein Abbruch hier bedeutet'],
$rows,
);
}
$this->newLine();
$this->line(' Gezählt werden eindeutige Firmen je Stufe — ein doppelter Klick ist kein doppelter Interessent.');
$this->line(' „ohne Firma" steht daneben statt darin: Diese Ereignisse gehören nicht in eine Quote, deren Nenner Firmen sind.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\CanonicalAssigner;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Doppelveröffentlichung erheben (Anhang 2, 1c.7): wie viele Meldungen
* liegen mit identischem Text derselben Firma auf beiden Portalen — gesamt,
* seit dem neuen System, je Hauptkategorie, mit und ohne Zuordnung. Dazu
* die Meldungen mit portal=both, die dieselbe Frage auf anderem Weg stellen.
*/
class ReportDuplicatePressReleases extends Command
{
protected $signature = 'press-releases:duplicates-report';
protected $description = 'Zählt identische Pressemitteilungen auf beiden Portalen (Doppelveröffentlichung).';
public function handle(CanonicalAssigner $assigner): int
{
$pairs = $assigner->pairsQuery();
$total = (clone $pairs)->count();
$recent = (clone $pairs)->where('a.created_at', '>=', '2026-07-01')->count();
$assigned = (clone $pairs)->where(fn ($q) => $q->whereColumn('a.canonical_press_release_id', '=', 'b.id')->orWhereColumn('b.canonical_press_release_id', '=', 'a.id'))->count();
$unhashed = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereNull('content_hash')->count();
$visible = PressRelease::query()->withoutGlobalScope(PortalScope::class)->visibleInArchive()
->selectRaw('portal, count(*) as total')->groupBy('portal')->toBase()->pluck('total', 'portal');
$this->info('Doppelveröffentlichung — Stand '.now()->format('d.m.Y H:i'));
$this->newLine();
$this->line(sprintf('Sichtbare Meldungen: presseecho %s, businessportal24 %s, both %s',
number_format((int) ($visible['presseecho'] ?? 0), 0, ',', '.'),
number_format((int) ($visible['businessportal24'] ?? 0), 0, ',', '.'),
number_format((int) ($visible['both'] ?? 0), 0, ',', '.')));
$this->line(sprintf('Paare gleichen Inhalts auf beiden Portalen: %s (%s URLs)', number_format($total, 0, ',', '.'), number_format($total * 2, 0, ',', '.')));
$this->line(sprintf(' davon seit 07/2026 (neues System): %s', number_format($recent, 0, ',', '.')));
$this->line(sprintf(' davon mit kanonischer Zuordnung: %s', number_format($assigned, 0, ',', '.')));
$this->line(sprintf('Meldungen ohne Prüfsumme: %s%s', number_format($unhashed, 0, ',', '.'), $unhashed > 0 ? ' — erst press-releases:hash-content laufen lassen' : ''));
$this->line(sprintf('Zuordnung (config canonical.enabled): %s', $assigner->enabled() ? 'aktiv' : 'aus'));
$this->newLine();
$byCategory = (clone $pairs)
->leftJoin('categories as c', 'c.id', '=', 'a.category_id')
->leftJoin('categories as root', 'root.id', '=', DB::raw('coalesce(c.parent_id, c.id)'))
->leftJoin('category_translations as t', fn ($join) => $join->on('t.category_id', '=', 'root.id')->where('t.locale', '=', 'de'))
->select(DB::raw('root.id as root_id, t.slug as slug, count(*) as pairs'))
->groupBy('root.id', 't.slug')
->orderByDesc('pairs')
->get();
$rows = $byCategory->map(function (object $row) use ($assigner): array {
$slug = (string) ($row->slug ?? '—');
$portal = $row->root_id !== null ? $assigner->preferredPortalForCategory((int) $row->root_id) : null;
return [
$slug,
number_format((int) $row->pairs, 0, ',', '.'),
$portal !== null ? $portal->label() : 'Fallback ('.config('canonical.fallback', 'first').')',
];
})->all();
$this->table(['Hauptkategorie', 'Paare', 'Erstquelle laut Zuordnung (E9)'], $rows);
return self::SUCCESS;
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\ViewDistributionReport;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Basiswert der Aufrufzahlen (Roadmap 0.1).
*
* Muss gemessen sein, BEVOR die erste Maßnahme aus Block 1 live geht — ohne
* Vergleichspunkt wird jede spätere Bewertung eine Vermutung. Die Verteilung
* entscheidet zugleich E2: ob die Absenderansicht Rohzahlen zeigt oder eine
* Einordnung gegen den Portalschnitt.
*/
class ReportPressReleaseViews extends Command
{
protected $signature = 'press-releases:view-distribution
{--months=3 : Zeitraum in Monaten rückwärts}
{--from= : Startdatum, überschreibt --months}
{--to= : Enddatum (Standard: heute)}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Basiswert: Verteilung der Aufrufzahlen je Portal (Median, Quartile, Häufigkeiten).';
public function handle(ViewDistributionReport $report): int
{
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
$from = $this->option('from') !== null
? Carbon::parse((string) $this->option('from'))
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
$data = $report->build($from, $to);
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info("Aufrufzahlen je Portal — veröffentlicht {$data['from']} bis {$data['to']}");
foreach ($data['portals'] as $portal) {
$this->newLine();
$this->line("<comment>{$portal['label']}</comment>");
$rows = [];
foreach ($portal['segments'] as $segment) {
$s = $segment['stats'];
if ($s['anzahl'] === 0) {
$rows[] = [$segment['segment'], 0, '–', '–', '–', '–', '–', '–', '–'];
continue;
}
$rows[] = [
$segment['segment'],
$s['anzahl'],
$s['min'],
$s['p10'],
$s['p25'],
$s['median'],
$s['p75'],
$s['p90'],
$s['max'],
];
}
$this->table(['Bestand', 'PMs', 'Min', 'P10', 'P25', 'Median', 'P75', 'P90', 'Max'], $rows);
$gesamt = $portal['segments'][0]['stats'];
if ($gesamt['anzahl'] === 0) {
continue;
}
$this->line(sprintf(
' Mittelwert %s gegenüber Median %d%s',
$gesamt['mittelwert'],
$gesamt['median'],
// Die Schiefe ist die eigentliche Antwort auf E2: Zieht eine
// Handvoll Ausreißer den Schnitt nach oben, liegt der typische
// Absender darunter und eine Rohzahl liest sich für ihn als
// Misserfolg.
$gesamt['mittelwert'] > $gesamt['median'] * 1.25
? ' ← rechtsschief, Rohzahlen wirken für die Mehrheit enttäuschend'
: '',
));
$verteilung = [];
foreach ($gesamt['verteilung'] as $klasse => $anzahl) {
$anteil = $gesamt['anzahl'] > 0 ? round($anzahl / $gesamt['anzahl'] * 100) : 0;
$verteilung[] = [$klasse.' Aufrufe', $anzahl, $anteil.' %'];
}
$this->table(['Klasse', 'PMs', 'Anteil'], $verteilung);
$kohorten = [];
foreach ($portal['cohorts'] as $cohort) {
$kohorten[] = [
$cohort['alter'],
$cohort['anzahl'],
$cohort['median'] ?? '–',
$cohort['mittelwert'] ?? '–',
];
}
if ($kohorten !== []) {
$this->line(" <comment>Aufrufe nach Alter am Stichtag {$data['stichtag']} (nur Meldungen seit dem Relaunch)</comment>");
$this->table(['Alter der Meldung', 'PMs', 'Median', 'Mittelwert'], $kohorten);
// Der Gesamtmedian oben hängt an der Altersmischung des
// Fensters und ist zwischen zwei Messzeitpunkten nicht
// vergleichbar. Die einzelne Kohorte ist es.
$this->line(' Als Basiswert die KOHORTE festhalten, nicht den Gesamtmedian — nur sie ist später identisch erhebbar.');
}
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\RejectionRateReport;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Console\Helper\TableSeparator;
/**
* Basiswert der Ablehnungsquote je Quelle (Roadmap 0.2).
*
* Als wiederkehrende Auswertung angelegt: Die Zahl wird für das
* connektar-Gespräch gebraucht und ist zugleich der Vergleichspunkt, gegen den
* später die Wirkung der Infoseite (1d) gemessen wird.
*/
class ReportRejectionRate extends Command
{
protected $signature = 'press-releases:rejection-rate
{--months=3 : Zeitraum in Monaten rückwärts}
{--from= : Startdatum, überschreibt --months}
{--to= : Enddatum (Standard: jetzt)}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Basiswert: Ablehnungsquote je Einreichungsquelle (API-Client, Kundenbereich, Redaktion).';
public function handle(RejectionRateReport $report): int
{
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
$from = $this->option('from') !== null
? Carbon::parse((string) $this->option('from'))
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
$data = $report->build($from, $to);
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info("Ablehnungsquote je Quelle — {$data['from']} bis {$data['to']}");
$this->newLine();
if ($data['sources'] === []) {
$this->warn('Keine Einreichungen im Zeitraum.');
return self::SUCCESS;
}
$rows = [];
foreach ($data['sources'] as $source) {
$rows[] = [
$source['label'],
$source['eingereicht'],
$source['veroeffentlicht'],
$source['abgelehnt'],
$source['offen'],
$source['quote'] === null ? '–' : $source['quote'].' %',
];
}
$t = $data['totals'];
$rows[] = new TableSeparator;
$rows[] = [
'<comment>gesamt</comment>',
$t['eingereicht'],
$t['veroeffentlicht'],
$t['abgelehnt'],
$t['offen'],
$t['quote'] === null ? '–' : '<comment>'.$t['quote'].' %</comment>',
];
$this->table(['Quelle', 'Eingereicht', 'Veröffentlicht', 'Abgelehnt', 'Offen', 'Quote'], $rows);
$this->line(' Quote = abgelehnt / entschieden. Noch offene Prüfungen zählen nicht in den Nenner.');
if ($data['entscheidungen_ohne_einreichung'] > 0) {
$this->newLine();
$this->warn(sprintf(
'%d Entscheidungen betreffen Einreichungen von VOR dem Zeitraum und sind keiner Quelle zugeordnet. '
.'Für einen belastbaren Basiswert den Zeitraum weiter zurück legen.',
$data['entscheidungen_ohne_einreichung'],
));
}
return self::SUCCESS;
}
}

View file

@ -14,6 +14,12 @@ use Illuminate\Console\Command;
* per Scheduler am Monatsersten (siehe routes/console.php). Bewusste
* Vereinfachung: Reset am Kalendermonat, nicht an der individuellen
* Abo-Periode ausgerichtet.
*
* Idempotent und periodengebunden (Audit F18): zurückgesetzt wird nur, was
* zu einem früheren Monat gehört (oder noch keinen Monat trägt). Verbrauch
* des laufenden Monats bleibt – ein doppelter oder verspäteter Lauf ist
* damit harmlos. Die fachliche Wahrheit liegt ohnehin in
* User::pressReleaseQuotaUsedThisMonth(), der Befehl räumt nur auf.
*/
class ResetMonthlyPressReleaseQuota extends Command
{
@ -29,9 +35,17 @@ class ResetMonthlyPressReleaseQuota extends Command
public function handle(): int
{
$period = User::currentQuotaPeriod();
$affected = User::query()
->where('press_release_quota_used_this_month', '>', 0)
->update(['press_release_quota_used_this_month' => 0]);
->where(fn ($query) => $query
->whereNull('press_release_quota_period')
->orWhere('press_release_quota_period', '<', $period))
->update([
'press_release_quota_used_this_month' => 0,
'press_release_quota_period' => $period,
]);
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));

View file

@ -0,0 +1,100 @@
<?php
namespace App\Console\Commands;
use App\Enums\NewsletterCampaignStatus;
use App\Enums\NewsletterRecipientStatus;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignSender;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Wiederanlauf einer laufenden Newsletter-Kampagne (Audit F14/F15): plant
* alle ausstehenden Empfänger neu ein — nach abgebrochenem Start, nach
* einer Pause des Circuit Breakers oder nach einem Worker-Absturz.
*
* `--failed` setzt fehlgeschlagene Empfänger zurück (z. B. nach behobener
* Relay-Störung). Empfänger, die länger als `--stuck-minutes` in „sending“
* hängen, gelten als Absturz-Reste: Ob die Mail dort schon raus war, ist
* unklar — der Aufrufer entscheidet mit der Frist, ob er das Risiko eines
* Doppelversands eingeht.
*/
class ResumeNewsletterCampaign extends Command
{
protected $signature = 'newsletter:resume
{campaign : ID der Kampagne im Status „sending“}
{--failed : Fehlgeschlagene Empfänger zurücksetzen und erneut einplanen}
{--stuck-minutes=30 : Empfänger, die länger als X Minuten in „sending“ hängen, zurücksetzen (0 = keine)}';
protected $description = 'Plant die ausstehenden Empfänger einer laufenden Newsletter-Kampagne erneut ein.';
public function handle(): int
{
if (! CampaignSender::sendingEnabled()) {
$this->error('Der Newsletter-Versand ist gesperrt (NEWSLETTER_SENDING_ENABLED=false) — nichts eingeplant.');
return self::FAILURE;
}
$campaign = NewsletterCampaign::query()->find((int) $this->argument('campaign'));
if ($campaign === null) {
$this->error('Kampagne nicht gefunden.');
return self::FAILURE;
}
if ($campaign->status !== NewsletterCampaignStatus::Sending) {
$this->error(sprintf('Kampagne #%d ist „%s“, nicht „%s“ — nichts zu tun.', $campaign->id, $campaign->status->label(), NewsletterCampaignStatus::Sending->label()));
return self::FAILURE;
}
$stuckMinutes = max(0, (int) $this->option('stuck-minutes'));
$stuck = 0;
if ($stuckMinutes > 0) {
$stuck = $campaign->recipients()
->where('status', NewsletterRecipientStatus::Sending->value)
->where('updated_at', '<', now()->subMinutes($stuckMinutes))
->update([
'status' => NewsletterRecipientStatus::Pending->value,
'error' => null,
'updated_at' => now(),
]);
}
$failed = 0;
if ($this->option('failed')) {
$failed = DB::transaction(function () use ($campaign): int {
$reset = $campaign->recipients()
->where('status', NewsletterRecipientStatus::Failed->value)
->update([
'status' => NewsletterRecipientStatus::Pending->value,
'error' => null,
'updated_at' => now(),
]);
if ($reset > 0) {
NewsletterCampaign::query()->whereKey($campaign->id)->decrement('failed_count', $reset);
}
return $reset;
});
}
$dispatched = CampaignSender::dispatchPending($campaign);
$this->info(sprintf(
'Kampagne #%d: %d Empfänger neu eingeplant (%d hängende zurückgesetzt, %d fehlgeschlagene zurückgesetzt).',
$campaign->id,
$dispatched,
$stuck,
$failed,
));
return self::SUCCESS;
}
}

View file

@ -84,7 +84,7 @@ class RetireAgencyCompanyType extends Command
continue;
}
if (! $isNewsroom && $isProtected) {
if (! $isNewsroom) {
$report['protected'][] = $entry + [
'third_party_members' => (bool) $agency->has_third_party_members,
'payment_option_links' => (bool) $agency->has_payment_option_links,

View file

@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage;
/**
* Legacy-Registrierungen mit bbcode-/HTML-Linkspam in den Stammdaten
* (Befund 17.09.2026: 4.391 aktive Firmen, Moncler/Chanel-Outlet-Muster,
* 2010–2022, nie eine veröffentlichte Meldung) werden deaktiviert — nicht
* gelöscht. Sie haben keine Newsroom-Seite, verzerren aber Firmensuche und
* Statistik. Die Besitzerkonten bleiben unangetastet: 24 von 44 führen auch
* saubere Firmen.
*
* Reversibel: Die IDs landen in einer Datei unter storage/app, und
* `is_active` lässt sich zurücksetzen.
*/
class RetireLinkSpamCompanies extends Command
{
private const MARKERS = ['[url=', '<a href'];
protected $signature = 'companies:retire-link-spam
{--dry-run : Nur zählen und Beispiele zeigen, nichts schreiben}';
protected $description = 'Deaktiviert Firmen mit bbcode-/HTML-Linkspam in den Stammdaten, die nie eine Meldung veröffentlicht haben.';
public function handle(ActivityLog $activityLog): int
{
$query = $this->candidates();
$count = (clone $query)->count();
if ($count === 0) {
$this->info('Keine Linkspam-Firmen gefunden.');
return self::SUCCESS;
}
$this->table(
['ID', 'Name', 'Angelegt', 'Adresse (Anfang)'],
(clone $query)->limit(10)->get()->map(fn (Company $company): array => [
$company->id,
mb_strimwidth((string) $company->name, 0, 50, '…'),
$company->created_at?->format('d.m.Y'),
mb_strimwidth(str_replace(["\r", "\n"], ' ', (string) $company->address), 0, 60, '…'),
])->all(),
);
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Firmen würden deaktiviert (10 Beispiele oben).', $count));
return self::SUCCESS;
}
$ids = (clone $query)->pluck('id');
$file = sprintf('companies-link-spam-%s.txt', now()->format('Y-m-d-His'));
Storage::disk('local')->put($file, $ids->implode("\n"));
$retired = 0;
foreach ($ids->chunk(500) as $chunk) {
$retired += Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('id', $chunk->all())
->update(['is_active' => false]);
}
$activityLog->record(
ActivityType::Correction,
sprintf('%d Legacy-Firmen mit Linkspam in den Stammdaten deaktiviert (IDs in storage/app/%s).', $retired, $file),
null,
null,
['count' => $retired, 'file' => $file],
'system',
);
$this->info(sprintf('%d Firmen deaktiviert. IDs: storage/app/%s', $retired, $file));
return self::SUCCESS;
}
/**
* @return Builder<Company>
*/
private function candidates(): Builder
{
return Company::query()
->withoutGlobalScope(PortalScope::class)
->where('is_active', true)
->whereNull('gone_at')
->where(function (Builder $query): void {
foreach (['address', 'boilerplate', 'name'] as $column) {
foreach (self::MARKERS as $marker) {
$query->orWhere($column, 'like', '%'.$marker.'%');
}
}
})
->whereDoesntHave('pressReleases', fn (Builder $q) => $q->withoutGlobalScope(PortalScope::class)->published())
->orderBy('id');
}
}

View file

@ -0,0 +1,224 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Company\ApiNewsroomAssignment;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Bestandsprüfung der Mitgliedschaften aus der API-Zuordnung (Roadmap 1a.4,
* Umsetzungsaudit 29.09.2026, R-01).
*
* Bis zum Fix hängte `POST /service/users/me/companies` jeden Token-Nutzer
* mit passender Firmen-E-Mail und passendem Namen als `member` an eine
* bestehende Firma — also auch fremde Einlieferer. Grundlage der Liste sind
* die Aktivitäts-Einträge „API-Firma … zugeordnet" seit dem Deploy von 1a.4
* (15.09.2026) und alle Pivots mit `granted_via = api`. Als „über die
* API entstanden" gilt ein `member`-Pivot, der mit oder nach der ersten
* Zuordnung angelegt wurde; wer vorher schon Mitglied war, wird nicht
* angefasst.
*
* Standard ist die reine Liste. `--remove` entfernt nach Vorschau und
* Rückfrage die Pivots von Nutzern, die weder freigeschaltet
* (`newsroom.api_assignment.allowed_user_ids`) noch Owner der Firma sind.
* Die Meldungen, die sie dort angelegt haben, bleiben über die Autorenschaft
* erreichbar.
*/
class ReviewApiCompanyAssignments extends Command
{
protected $signature = 'company-memberships:api-assignments
{--since=2026-09-15 : Aktivitäts-Einträge der API-Zuordnung ab diesem Datum auswerten (Deploy von 1a.4)}
{--remove : Mitgliedschaften nicht freigeschalteter Einlieferer entfernen (mit Vorschau und Rückfrage)}
{--force : Rückfrage bei --remove überspringen (nicht interaktiv)}';
protected $description = 'Listet Firmen-Mitgliedschaften aus der API-Zuordnung und entfernt auf Wunsch die nicht freigeschalteter Einlieferer.';
/**
* Karenz zwischen Pivot und Aktivitäts-Eintrag: beide entstehen im selben
* Request, der Pivot wenige Millisekunden vorher.
*/
private const EVENT_TOLERANCE_SECONDS = 60;
public function handle(ActivityLog $activityLog): int
{
$rows = $this->assignmentRows(Carbon::parse((string) $this->option('since'))->startOfDay());
$foreign = $rows->reject(fn (object $row): bool => $row->allowed)->values();
$this->info(sprintf(
'%d über die API-Zuordnung entstandene Mitgliedschaft(en), davon %d ohne Freischaltung.',
$rows->count(),
$foreign->count(),
));
if ($rows->isNotEmpty()) {
$this->table(
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
$rows->take(100)->map(fn (object $row): array => $this->tableRow($row))->all(),
);
}
if (! $this->option('remove')) {
if ($foreign->isNotEmpty()) {
$this->line('Entfernen mit --remove (zeigt vorher die betroffenen Zeilen).');
}
return self::SUCCESS;
}
if ($foreign->isEmpty()) {
$this->info('Nichts zu entfernen.');
return self::SUCCESS;
}
$this->warn(sprintf('Zu entfernen (%d):', $foreign->count()));
$this->table(
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
$foreign->map(fn (object $row): array => $this->tableRow($row))->all(),
);
if (! $this->option('force') && ! $this->confirm(sprintf('%d Mitgliedschaft(en) entfernen?', $foreign->count()))) {
$this->line('Abgebrochen, nichts geändert.');
return self::SUCCESS;
}
$removed = 0;
foreach ($foreign as $row) {
$deleted = DB::table('company_user')
->where('company_id', $row->company_id)
->where('user_id', $row->user_id)
->where('role', 'member')
->delete();
if ($deleted === 0) {
continue;
}
$removed++;
$activityLog->record(
ActivityType::Correction,
sprintf('API-Mitgliedschaft von User #%d (%s) an Firma #%d „%s“ entfernt (Audit R-01)', $row->user_id, $row->email, $row->company_id, $row->company_name),
subject: Company::withoutGlobalScope(PortalScope::class)->find($row->company_id),
meta: ['api_assignment_removed' => true, 'user_id' => $row->user_id, 'granted_via' => $row->granted_via],
actorLabel: 'console',
);
}
$this->info(sprintf('%d Mitgliedschaft(en) entfernt.', $removed));
return self::SUCCESS;
}
/**
* Pivots aus der API-Zuordnung: aus dem Aktivitäts-Log (Altfälle ohne
* Herkunft) und mit `granted_via = api` (ab dem Fix).
*
* @return Collection<int, object>
*/
private function assignmentRows(Carbon $since): Collection
{
/** @var array<string, Carbon> $firstEvents "company:user" => erste Zuordnung */
$firstEvents = [];
ActivityEvent::query()
->where('type', ActivityType::Correction->value)
->where('subject_type', (new Company)->getMorphClass())
->where('description', 'like', 'API-Firma %')
->where('occurred_at', '>=', $since)
->whereNotNull('user_id')
->orderBy('id')
->select(['id', 'user_id', 'subject_id', 'occurred_at'])
->chunkById(1000, function (Collection $events) use (&$firstEvents): void {
foreach ($events as $event) {
$key = $event->subject_id.':'.$event->user_id;
$firstEvents[$key] ??= $event->occurred_at;
}
});
$pairs = collect(array_keys($firstEvents))->map(fn (string $key): array => array_map('intval', explode(':', $key)));
$rows = collect();
foreach ($pairs->chunk(500) as $chunk) {
$rows = $rows->merge($this->pivotQuery()
->where(function ($query) use ($chunk): void {
foreach ($chunk as [$companyId, $userId]) {
$query->orWhere(fn ($pair) => $pair->where('company_user.company_id', $companyId)->where('company_user.user_id', $userId));
}
})
->get());
}
$rows = $rows->merge($this->pivotQuery()->where('company_user.granted_via', ApiNewsroomAssignment::GRANTED_VIA)->get());
return $rows
->unique(fn (object $row): string => $row->company_id.':'.$row->user_id)
->filter(function (object $row) use ($firstEvents): bool {
if ((int) $row->owner_user_id === (int) $row->user_id) {
return false;
}
if ($row->granted_via === ApiNewsroomAssignment::GRANTED_VIA) {
return true;
}
// Schon vor der ersten Zuordnung Mitglied: nicht durch die API entstanden.
$first = $firstEvents[$row->company_id.':'.$row->user_id] ?? null;
return $first !== null
&& $row->created_at !== null
&& Carbon::parse($row->created_at)->greaterThanOrEqualTo($first->copy()->subSeconds(self::EVENT_TOLERANCE_SECONDS));
})
->map(function (object $row): object {
$row->allowed = ApiNewsroomAssignment::isAllowed((int) $row->user_id);
return $row;
})
->sortBy(['company_id', 'user_id'])
->values();
}
/**
* `member`-Pivots samt Firma und Nutzer — `responsible`/`owner` stammen
* nie aus der API-Zuordnung (eine Hochstufung hat eine E-Mail-Grundlage).
*/
private function pivotQuery(): Builder
{
return DB::table('company_user')
->join('companies', 'companies.id', '=', 'company_user.company_id')
->join('users', 'users.id', '=', 'company_user.user_id')
->where('company_user.role', 'member')
->select([
'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at',
'companies.name as company_name', 'companies.owner_user_id', 'users.email',
]);
}
/**
* @return list<string>
*/
private function tableRow(object $row): array
{
return [
(string) $row->company_id,
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
(string) $row->user_id,
(string) $row->email,
(string) $row->granted_via,
(string) $row->created_at,
$row->allowed ? 'ja' : 'nein',
];
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Support\WebPageCache;
use Illuminate\Console\Command;
/**
* Beschreibungen der Branchenseiten einspielen (Roadmap 2.3). Ohne eigene
* Beschreibung zeigen alle Branchenseiten denselben Standardsatz — für
* Suchmaschinen 14 gleich aussehende Seiten. Texte liegen in
* database/seeders/data/category-descriptions.php, Schlüssel ist der
* DE-Slug. Nur leere Felder werden gefüllt; --force überschreibt.
*/
class SeedCategoryDescriptions extends Command
{
protected $signature = 'categories:seed-descriptions
{--dry-run : Nur zeigen, was geschrieben würde}
{--force : Auch vorhandene Beschreibungen überschreiben}';
protected $description = 'Füllt leere Kategoriebeschreibungen (DE/EN) aus database/seeders/data/category-descriptions.php.';
public function handle(): int
{
/** @var array<string, array<string, string>> $texts */
$texts = require database_path('seeders/data/category-descriptions.php');
$rows = [];
$written = 0;
foreach ($texts as $slug => $byLocale) {
$category = Category::query()
->whereHas('translations', fn ($query) => $query->where('locale', 'de')->where('slug', $slug))
->with('translations')
->first();
if (! $category) {
$rows[] = [$slug, '—', 'Kategorie nicht gefunden'];
continue;
}
foreach ($byLocale as $locale => $text) {
/** @var CategoryTranslation|null $translation */
$translation = $category->translations->firstWhere('locale', $locale);
if (! $translation) {
$rows[] = [$slug, $locale, 'keine Übersetzung'];
continue;
}
if (filled($translation->description) && ! $this->option('force')) {
$rows[] = [$slug, $locale, 'bereits vorhanden, übersprungen'];
continue;
}
$rows[] = [$slug, $locale, mb_strimwidth($text, 0, 60, '…')];
if (! $this->option('dry-run')) {
$translation->forceFill(['description' => $text])->save();
$written++;
}
}
}
$this->table(['Kategorie', 'Sprache', 'Ergebnis'], $rows);
if ($this->option('dry-run')) {
$this->info('[Dry-Run] Nichts geschrieben.');
return self::SUCCESS;
}
WebPageCache::flush();
$this->info(sprintf('%d Beschreibungen geschrieben, Seiten-Cache geleert.', $written));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,358 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Billing\LegacySubscriptionService;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
*
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
* anhand des Zahlungsstatus der jeweiligen Quellrechnung auf:
*
* - `hold` → Kunde zahlt seit Jahren nicht: Bestandsschutz-Bündel kündigen,
* offene MAN-Rechnungen des Kunden stornieren. Die Archiv-
* Rechnungen bleiben unverändert erhalten (werden nur ausgeblendet).
* - `pending` / `reminder` (wiederkehrend) → echte offene Forderung 2026:
* Periode auf die unbezahlte Rechnung zurückdrehen und sofort eine
* MAN-Rechnung ausstellen, die die Alt-Rechnung ersetzt
* (Archiv-Status `superseded`). Ab da gilt der Knopfdruck-Workflow.
* - `pending` / `reminder` (Einzelkauf) → abschreiben (`uncollectible`).
* - `paid` → unangetastet.
*
* Wiederholbar: Bereits gekündigte, stornierte oder ersetzte Vorgänge werden
* beim nächsten Lauf übersprungen.
*
* Verwendung:
* php artisan legacy:settle-unpaid --dry-run
* php artisan legacy:settle-unpaid
*/
class SettleUnpaidLegacyInvoices extends Command
{
protected $signature = 'legacy:settle-unpaid
{--dry-run : Nur anzeigen, nichts schreiben}
{--no-invoice : Offene Vorgänge nur zurückdrehen, MAN-Rechnung dem 04:30-Lauf überlassen}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Löst Bestandsschutz-Posten aus unbezahlten Legacy-Rechnungen auf (hold kündigen, offene Vorgänge neu ausstellen, Einzelkäufe abschreiben).';
private const VOID_REASON_HOLD = 'Vorjahresrechnung im Altsystem nie bezahlt (hold) — Bestandsschutz beendet.';
/** @var array<string, list<string>> */
private array $report = [];
private bool $dryRun = false;
public function handle(
LegacySubscriptionService $legacy,
ManualInvoiceService $manualInvoices,
ActivityLog $activityLog,
): int {
$this->dryRun = (bool) $this->option('dry-run');
$this->report = [
'bundles_cancelled' => [],
'man_invoices_voided' => [],
'reopened' => [],
'man_invoices_issued' => [],
'singles_written_off' => [],
'conflicts' => [],
'untouched_paid' => [],
'already_reissued' => [],
];
if ($this->dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$postsByUser = UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->with('user')
->get()
->groupBy('user_id');
foreach ($postsByUser as $userId => $posts) {
$this->settleBundle((int) $userId, $posts, $legacy, $manualInvoices, $activityLog);
}
$this->writeOffSingles();
$this->table(
['Ergebnis', 'Anzahl'],
collect($this->report)->map(fn (array $lines, string $key): array => [$key, count($lines)])->values()->all(),
);
foreach ($this->report['conflicts'] as $line) {
$this->warn('Konflikt: '.$line);
}
if (! $this->option('no-report')) {
$path = sprintf('migration/settle-unpaid-%s.json', now()->format('Ymd-His'));
Storage::put($path, json_encode([
'generated_at' => now()->toIso8601String(),
'dry_run' => $this->dryRun,
...$this->report,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->info("Report: storage/app/{$path}");
}
return self::SUCCESS;
}
/**
* Ein Kunde = ein Bündel. Die Kündigung ist nur gemeinsam möglich, daher
* wird nur gekündigt, wenn ALLE Posten des Kunden aus `hold`-Rechnungen
* stammen; gemischte Bündel landen als Konflikt im Report.
*
* @param Collection<int, UserPaymentOption> $posts
*/
private function settleBundle(
int $userId,
Collection $posts,
LegacySubscriptionService $legacy,
ManualInvoiceService $manualInvoices,
ActivityLog $activityLog,
): void {
$classified = $posts->map(fn (UserPaymentOption $post): array => [
'post' => $post,
'source' => $this->sourceInvoice($post),
]);
$statuses = $classified
->map(fn (array $entry): string => $entry['source']->status ?? 'unknown')
->unique()
->values();
$user = $posts->first()?->user;
if ($statuses->count() === 1 && $statuses->first() === LegacyInvoice::STATUS_HOLD) {
$this->cancelHoldBundle($userId, $user, $classified, $legacy, $activityLog);
return;
}
foreach ($classified as ['post' => $post, 'source' => $source]) {
$status = $source?->status;
if ($status === 'paid') {
$this->report['untouched_paid'][] = $this->describe($post, $source);
continue;
}
if (in_array($status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
$this->reopen($post, $source, $manualInvoices, $activityLog);
continue;
}
// Ein früherer Lauf hat die Forderung schon als MAN-Rechnung neu
// ausgestellt — der Posten bleibt Bestandsschutz, die Quelle trägt
// `superseded`. Kein Konflikt, nichts zu tun.
if ($status === LegacyInvoice::STATUS_SUPERSEDED) {
$this->report['already_reissued'][] = $this->describe($post, $source).' · ersetzt durch '.($source->raw_snapshot['superseded_by_invoice_number'] ?? '?');
continue;
}
// hold im gemischten Bündel oder Quelle nicht auffindbar → manuell klären.
$this->report['conflicts'][] = $this->describe($post, $source).' · Status '.($status ?? 'unbekannt').' im gemischten Bündel';
}
}
/**
* @param Collection<int, array{post: UserPaymentOption, source: ?LegacyInvoice}> $classified
*/
private function cancelHoldBundle(
int $userId,
?User $user,
Collection $classified,
LegacySubscriptionService $legacy,
ActivityLog $activityLog,
): void {
foreach ($classified as ['post' => $post, 'source' => $source]) {
$this->report['bundles_cancelled'][] = $this->describe($post, $source);
}
$openInvoices = Invoice::query()
->manual()
->open()
->where('user_id', $userId)
->get();
foreach ($openInvoices as $invoice) {
$this->report['man_invoices_voided'][] = sprintf('%s · User #%d · %s €', $invoice->number, $userId, $this->euro($invoice->total_cents));
}
if ($this->dryRun || ! $user) {
return;
}
DB::transaction(function () use ($user, $openInvoices, $legacy, $activityLog): void {
$count = $legacy->cancelBundleFor($user);
foreach ($openInvoices as $invoice) {
$invoice->update([
'status' => InvoiceStatus::Void->value,
'voided_at' => now(),
'void_reason' => self::VOID_REASON_HOLD,
]);
$activityLog->record(
ActivityType::ManualInvoice,
sprintf('Rechnung %s storniert: %s', $invoice->number, self::VOID_REASON_HOLD),
$user,
$invoice,
['command' => $this->getName()],
actorLabel: 'legacy:settle-unpaid',
);
}
$activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz beendet (%d Posten): Quellrechnung im Altsystem nie bezahlt (hold).', $count),
$user,
null,
['command' => $this->getName(), 'posts' => $count],
actorLabel: 'legacy:settle-unpaid',
);
});
}
/**
* Periode auf die unbezahlte Alt-Rechnung zurückdrehen und die offene
* Forderung als MAN-Rechnung neu ausstellen.
*/
private function reopen(
UserPaymentOption $post,
LegacyInvoice $source,
ManualInvoiceService $manualInvoices,
ActivityLog $activityLog,
): void {
// Die unbezahlte Periode beginnt am Leistungsbeginn der Alt-Rechnung;
// dorthin wird `current_period_end` zurückgedreht, damit der MAN-Kreis
// genau diese Periode abrechnet und den gewohnten Rhythmus fortführt.
$periodEnd = $this->legacyPeriodBegin($source);
$periodStart = $periodEnd->copy()->subYear();
$this->report['reopened'][] = $this->describe($post, $source).sprintf(' · Periode → %s..%s', $periodStart->toDateString(), $periodEnd->toDateString());
if ($this->dryRun) {
return;
}
$conditions = $post->legacy_conditions ?? [];
$conditions['open_legacy_invoice'] = [
'id' => $source->id,
'number' => $source->number,
'invoice_date' => $source->invoice_date->toDateString(),
'total_cents' => $source->total_cents,
];
$post->update([
'legacy_conditions' => $conditions,
'current_period_start' => $periodStart->toDateString(),
'current_period_end' => $periodEnd->toDateString(),
]);
$activityLog->record(
ActivityType::ManualInvoice,
sprintf('Offene Alt-Rechnung %s (%s €) wird im MAN-Kreis neu ausgestellt.', $source->number, $this->euro($source->total_cents)),
$post->user,
$post,
['command' => $this->getName(), 'legacy_invoice_id' => $source->id],
actorLabel: 'legacy:settle-unpaid',
);
if ($this->option('no-invoice')) {
return;
}
$invoice = $manualInvoices->invoiceFor($post->fresh());
if ($invoice) {
$this->report['man_invoices_issued'][] = sprintf('%s ersetzt %s · User #%d · %s €', $invoice->number, $source->number, $post->user_id, $this->euro($invoice->total_cents));
} else {
$this->report['conflicts'][] = $this->describe($post, $source).' · MAN-Rechnung nicht ausstellbar (Rechnungsadresse fehlt?) — 04:30-Lauf versucht es erneut';
}
}
/**
* Unbezahlte Einzelkäufe (19,95-€-PMs) sind keine Bestandsschutz-Fälle
* und werden abgeschrieben (Entscheidung E8).
*/
private function writeOffSingles(): void
{
$singles = LegacyInvoice::query()
->openLegacy()
->get()
->filter(fn (LegacyInvoice $invoice): bool => ($invoice->pdf_payload['payment_option']['type'] ?? null) === 'single');
foreach ($singles as $invoice) {
$this->report['singles_written_off'][] = sprintf('%s · User #%s · %s €', $invoice->number, $invoice->user_id ?? '-', $this->euro($invoice->total_cents));
if (! $this->dryRun) {
$invoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
}
}
}
private function sourceInvoice(UserPaymentOption $post): ?LegacyInvoice
{
$conditions = $post->legacy_conditions ?? [];
$number = $conditions['source_invoice_number'] ?? null;
if (! $number) {
return null;
}
return LegacyInvoice::query()
->where('number', $number)
->when($conditions['legacy_portal'] ?? null, fn ($query, string $portal) => $query->where('legacy_portal', $portal))
->first();
}
private function legacyPeriodBegin(LegacyInvoice $source): Carbon
{
$begin = $source->raw_snapshot['service_period_begin_date'] ?? null;
if ($begin && ! str_starts_with((string) $begin, '0000-00-00')) {
return Carbon::parse(substr((string) $begin, 0, 10))->startOfDay();
}
return Carbon::instance($source->invoice_date)->startOfDay();
}
private function describe(UserPaymentOption $post, ?LegacyInvoice $source): string
{
return sprintf(
'UPO #%d · User #%d · Quelle %s (%s, %s)',
$post->id,
$post->user_id,
$source->number ?? '?',
$source->status ?? 'unbekannt',
$source?->invoice_date?->toDateString() ?? '-',
);
}
private function euro(int $cents): string
{
return number_format($cents / 100, 2, ',', '.');
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Company\CompanyNameNormalizer;
use App\Support\WebCaches;
use Illuminate\Console\Command;
/**
* Bestandsbereinigung zu CompanyNameNormalizer (17.09.2026): Firmennamen
* mit Link („Firma - https://…", „www.beispiel.de") werden einmalig
* bereinigt, die URL wandert ins leere Website-Feld. Slugs bleiben — die
* Newsroom-URLs sind draußen verlinkt.
*/
class StripLinksFromCompanyNames extends Command
{
protected $signature = 'companies:strip-links-from-names
{--dry-run : Nur Vorher/Nachher zeigen, nichts schreiben}
{--all : Auch inaktive Firmen ohne veröffentlichte Meldung}';
protected $description = 'Zieht Links aus Firmennamen und legt sie ins Website-Feld (Bestandsbereinigung).';
public function handle(ActivityLog $activityLog): int
{
$query = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereNull('gone_at')
// Grobfilter in SQL (portabel, SQLite kennt kein REGEXP), die
// eigentliche Erkennung macht der Normalizer in PHP.
->where(fn ($q) => $q->where('name', 'like', '%http://%')
->orWhere('name', 'like', '%https://%')
->orWhere('name', 'like', '%www.%')
->orWhere('name', 'like', '%/%'))
->orderBy('id');
// Ohne --all nur, was jemand sehen kann: aktive Firmen mit
// veröffentlichter Meldung. Der Rest ist Datenmüll ohne Newsroom-Seite.
if (! $this->option('all')) {
$query->where('is_active', true)
->whereHas('pressReleases', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published'));
}
$rows = [];
$changes = [];
foreach ($query->cursor() as $company) {
if (! CompanyNameNormalizer::containsUrl((string) $company->name)) {
continue;
}
$normalized = CompanyNameNormalizer::normalize((string) $company->name, $company->website);
if ($normalized['removed'] === [] || $normalized['name'] === $company->name) {
continue;
}
$rows[] = [$company->id, $company->name, $normalized['name'], $normalized['website'] ?? '—'];
$changes[] = [$company, $normalized];
}
if ($rows === []) {
$this->info('Keine Firmennamen mit Link gefunden.');
return self::SUCCESS;
}
$this->table(['ID', 'Vorher', 'Nachher', 'Website'], $rows);
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Firmennamen würden bereinigt.', count($rows)));
return self::SUCCESS;
}
foreach ($changes as [$company, $normalized]) {
$before = $company->name;
$company->forceFill([
'name' => $normalized['name'],
'website' => $normalized['website'],
])->save();
$activityLog->record(
ActivityType::Correction,
sprintf('Firma #%d: Link aus dem Namen entfernt („%s“ → „%s“)', $company->getKey(), $before, $company->name),
null,
$company,
['removed' => $normalized['removed']],
'system',
);
}
WebCaches::forgetPublicPages();
$this->info(sprintf('%d Firmennamen bereinigt.', count($changes)));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
/**
* Umsetzungsaudit 29.09.2026, B-11: Beweisoriginale ohne Bildzeile (harter
* Abbruch nach dem Schreiben des Originals, z. B. memory_limit bei
* 8000 × 8000 px) nach einer Karenzzeit entfernen.
*
* Standard ist ein Trockenlauf. Dateien, auf die eine Bildzeile zeigt —
* auch weich gelöschte (Beweisarchiv ausstehend) —, bleiben immer liegen.
* Nach einem Datenbank-Restore fehlen Zeilen, deren Originale noch Beweis
* sind: Solange der Archiv-Sweep ausgesetzt ist (`archive:sweep-orphans
* --pause`, setzt auch `archive:reconcile-after-restore`), löscht auch dieser
* Befehl nichts.
*/
class SweepOrphanPressReleaseOriginals extends Command
{
protected $signature = 'press-release-images:sweep-orphan-originals
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
{--hours=48 : Nur Dateien, die älter als X Stunden sind}';
protected $description = 'Entfernt Beweisoriginale ohne zugehörige Bildzeile (B-11)';
public function handle(PressReleaseImageRemover $remover, EvidenceArchive $archive): int
{
$execute = (bool) $this->option('execute');
if ($execute && $archive->isSweepPaused()) {
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich) — nichts gelöscht. Fortsetzen mit archive:sweep-orphans --resume.');
return self::SUCCESS;
}
$result = $remover->sweepOrphanOriginals(
$execute,
(int) $this->option('hours'),
fn (string $file) => $this->line(' verwaist: '.$file),
);
$this->info(sprintf(
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
$execute ? 'Bereinigung' : 'Trockenlauf',
$result['scanned'],
$result['orphans'],
$result['removed'],
));
return $execute && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Console\Commands;
use App\Models\CompanyMerge;
use App\Services\Company\CompanyMergeService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
/**
* Rückabwicklung einer Zusammenführung (Roadmap 1a.3): einen Vorgang, alle
* eines Laufs, oder alle einer Adresse. Spielt die protokollierten Bewegungen
* rückwärts ab; was seither neu beim Überlebenden entstand, bleibt dort.
*/
class UnmergeCompanies extends Command
{
protected $signature = 'companies:unmerge
{--merge= : ID eines Vorgangs aus company_merges}
{--batch= : Alle Vorgänge eines Laufs}
{--email= : Alle Vorgänge unter dieser Adresse}
{--dry-run : Nur zählen}';
protected $description = 'Macht eine Newsroom-Zusammenführung rückgängig.';
public function handle(CompanyMergeService $service): int
{
if (! $this->option('merge') && ! $this->option('batch') && ! $this->option('email')) {
$this->error('Bitte --merge=, --batch= oder --email= angeben.');
return self::INVALID;
}
$query = CompanyMerge::query()
->whereNull('reverted_at')
->when($this->option('merge'), fn (Builder $query, string $id) => $query->whereKey((int) $id))
->when($this->option('batch'), fn (Builder $query, string $batch) => $query->where('batch', $batch))
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', mb_strtolower(trim($email))))
->orderByDesc('id');
$merges = $query->get();
if ($merges->isEmpty()) {
$this->warn('Kein offener Vorgang gefunden.');
return self::SUCCESS;
}
$companies = $merges->sum('merged_companies_count');
$releases = $merges->sum('moved_press_releases_count');
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Vorgänge würden rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
return self::SUCCESS;
}
foreach ($merges as $merge) {
$service->revert($merge, flushCaches: false);
}
$service->flushCaches();
$this->info(sprintf('%d Vorgänge rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
return self::SUCCESS;
}
}

View file

@ -27,6 +27,22 @@ enum ActivityType: string
case SyncLock = 'sync_lock';
case Anonymization = 'anonymization';
case Correction = 'correction';
case ContentRemoval = 'content_removal';
case MailSuppressed = 'mail_suppressed';
case ManualInvoice = 'manual_invoice';
/** Zugriff auf Beweisoriginal oder veröffentlichte Bildfassung (Bildkennzeichnung E.4). */
case EvidenceAccess = 'evidence_access';
/** Korrekturweg Bildnachweis/Kennzeichnung (Bildkennzeichnung E.6). */
case ImageCorrection = 'image_correction';
/** Fehlgeschlagene Anmeldung, Sperre, falscher 2FA-Code (Monitoring-Plan 1.7). */
case LoginFailed = 'login_failed';
case LoginLockout = 'login_lockout';
case TwoFactorFailed = 'two_factor_failed';
public function label(): string
{
@ -49,6 +65,14 @@ enum ActivityType: string
self::SyncLock => 'Re-Sync-Sperre',
self::Anonymization => 'Anonymisierung',
self::Correction => 'Korrektur',
self::ContentRemoval => 'Entfernung (410)',
self::MailSuppressed => 'Mail-Sperre',
self::ManualInvoice => 'Rechnung (MAN)',
self::EvidenceAccess => 'Beweiszugriff',
self::ImageCorrection => 'Bildkorrektur',
self::LoginFailed => 'Anmeldung fehlgeschlagen',
self::LoginLockout => 'Anmeldesperre',
self::TwoFactorFailed => '2FA fehlgeschlagen',
};
}
@ -71,6 +95,13 @@ enum ActivityType: string
self::SyncLock => 'red',
self::Anonymization => 'red',
self::Correction => 'blue',
self::ContentRemoval => 'red',
self::EvidenceAccess => 'red',
self::ImageCorrection => 'blue',
self::MailSuppressed => 'orange',
self::ManualInvoice => 'amber',
self::LoginFailed, self::TwoFactorFailed => 'orange',
self::LoginLockout => 'red',
};
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
/**
* Woher die effektive KI-Kennzeichnung eines Bildes stammt (Decision-Update
* S6): Angabe des Kunden, Vorbelegung aus ungesicherten Metadaten, geprüfte
* Content Credentials oder eine Admin-Korrektur. Wird öffentlich im
* Detailfenster genannt („Angabe des Einreichers" usw.).
*/
enum AiDeclarationSource: string
{
case Customer = 'customer';
case IptcPrefill = 'iptc_prefill';
case C2paVerified = 'c2pa_verified';
case AdminCorrection = 'admin_correction';
public function publicLabel(): string
{
return match ($this) {
self::Customer => __('Angabe des Einreichers'),
self::IptcPrefill => __('aus Metadaten der Datei vorbelegt, vom Einreicher bestätigt'),
self::C2paVerified => __('aus geprüften Herkunftsdaten der Datei'),
self::AdminCorrection => __('vom Portal nach Prüfung korrigiert'),
};
}
}

View file

@ -8,6 +8,11 @@ namespace App\Enums;
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
*
* Seit Formular v2 (Bildkennzeichnung, 22.09.2026) heißt die erste Option
* „Ohne generative KI erstellt oder bearbeitet": Sie gilt auch für Logos,
* Grafiken und Screenshots, nicht nur für Fotos (Feedback F11). Der
* gespeicherte Wert `original` bleibt aus Kompatibilitätsgründen.
*/
enum AiImageDeclaration: string
{
@ -21,9 +26,9 @@ enum AiImageDeclaration: string
public function label(): string
{
return match ($this) {
self::Original => __('Originalfoto'),
self::AiGenerated => __('KI-generiertes Bild'),
self::AiEdited => __('KI-bearbeitetes Bild'),
self::Original => __('Ohne generative KI erstellt oder bearbeitet'),
self::AiGenerated => __('KI-generiert'),
self::AiEdited => __('KI-bearbeitet (z. B. generative Füllung, KI-Retusche)'),
};
}
@ -41,6 +46,40 @@ enum AiImageDeclaration: string
};
}
/**
* Rangfolge für die Portalregel „Kennzeichnung = max(geprüfter Befund,
* Kundenangabe)" (Bildkennzeichnung A.4). Das ist eine bewusste
* Portalregel und keine Aussage über den Wahrheitsgehalt: Ein
* KI-erzeugter Bestandteil macht ein Bild nicht automatisch vollständig
* KI-generiert (Feedback F11) — der Befund wird vorher fachlich
* zugeordnet, erst dann greift die Mindestkennzeichnung.
*/
public function rank(): int
{
return match ($this) {
self::Original => 0,
self::AiEdited => 1,
self::AiGenerated => 2,
};
}
/**
* Die strengere von zwei Kennzeichnungen nach rank(); null zählt als
* „keine Angabe".
*/
public static function strictest(?self $a, ?self $b): ?self
{
if ($a === null) {
return $b;
}
if ($b === null) {
return $a;
}
return $b->rank() > $a->rank() ? $b : $a;
}
/**
* Optionsliste für Radios/Selects: value => label.
*

View file

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
/**
* Auslöser eines Archiveintrags (Beweisarchiv, Decision-Update 4.1/4.2).
*/
enum ArchiveEvent: string
{
case ImageRemoved = 'image_removed';
case ImageReplaced = 'image_replaced';
case PressReleaseDepublished = 'pm_depublished';
case GdprChange = 'gdpr_change';
case LabelCorrected = 'label_corrected';
public function label(): string
{
return match ($this) {
self::ImageRemoved => 'Bild entfernt',
self::ImageReplaced => 'Bild ersetzt',
self::PressReleaseDepublished => 'Meldung depubliziert',
self::GdprChange => 'DSGVO-Änderung',
self::LabelCorrected => 'Kennzeichnung korrigiert',
};
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Enums;
/**
* Grund der Entfernung als Kategorie (Beweisarchiv 4.2) — nie als Freitext
* mit Altwerten, die gehören in den verschlüsselten Payload.
*/
enum ArchiveReasonCategory: string
{
case CustomerRequest = 'customer_request';
case ReportProcedure = 'report_procedure';
case Gdpr = 'gdpr';
case TermEnd = 'term_end';
case OwnDecision = 'own_decision';
case Correction = 'correction';
public function label(): string
{
return match ($this) {
self::CustomerRequest => 'Antrag des Kunden',
self::ReportProcedure => 'Meldeverfahren',
self::Gdpr => 'DSGVO',
self::TermEnd => 'Laufzeitende',
self::OwnDecision => 'Eigene Entscheidung',
self::Correction => 'Korrektur',
};
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Enums;
/**
* Klassifizierung einer Firmen-E-Mail-Adresse für die Newsroom-Konsolidierung
* (Roadmap 1a.2).
*
* Eine Eigenschaft der ADRESSE, nicht der Firma: Ob zusammengeführt werden
* darf, entscheidet sich daran, was alles unter dieser Adresse steht.
*/
enum CompanyAddressClass: string
{
case Duplicate = 'dublette';
case NameVariant = 'namensvariante';
case Proposal = 'vorschlag';
case CollectionAddress = 'sammeladresse';
case NeverPublished = 'nie_veroeffentlicht';
public function label(): string
{
return match ($this) {
self::Duplicate => 'Dublette',
self::NameVariant => 'Namensvariante',
self::Proposal => 'Vorschlag',
self::CollectionAddress => 'Sammeladresse',
self::NeverPublished => 'nie veröffentlicht',
};
}
public function description(): string
{
return match ($this) {
self::Duplicate => 'identischer Name — automatisch zusammenführbar',
self::NameVariant => 'nur Rechtsform/Schreibweise abweichend — automatisch zusammenführbar',
self::Proposal => 'abweichende Namen unter dem Schwellwert — Vorschlag im Admin, keine automatische Aktion',
self::CollectionAddress => 'Agentur oder Dienstleister — markieren, nicht zusammenführen',
self::NeverPublished => 'nie eine Veröffentlichung — von jeder automatischen Aktion ausnehmen, einzeln prüfen',
};
}
/**
* Darf über diese Adresse automatisch zusammengeführt werden?
*
* Nur die beiden Klassen, bei denen unter der Adresse nachweislich EINE
* Firma steht. Alles andere bleibt stehen — eine Sammeladresse würde
* Kunden verschmelzen, eine Platzhalteradresse Tausende Unbeteiligte.
*/
public function isMergeable(): bool
{
return in_array($this, [self::Duplicate, self::NameVariant], true);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Enums;
/**
* Was bei einer Newsroom-Zusammenführung (Roadmap 1a.3) verschoben wurde.
* Jede Zeile in `company_merge_moves` trägt genau eine dieser Arten; die
* Rückabwicklung entscheidet daran, wie sie den Datensatz zurückholt.
*/
enum CompanyMergeSubject: string
{
/** `press_releases.company_id`, subject_id = Meldung */
case PressRelease = 'press_release';
/** `contacts.company_id`, subject_id = Kontakt */
case Contact = 'contact';
/** `press_release_ai_declarations.company_id`, subject_id = Erklärung */
case AiDeclaration = 'ai_declaration';
/** `conversion_events.company_id`, subject_id = Ereignis */
case ConversionEvent = 'conversion_event';
/** `company_user`, subject_id = Nutzer; previous_value = Rolle; dropped, wenn der Nutzer am Überlebenden schon stand */
case CompanyUser = 'company_user';
/** `user_payment_option_company`, subject_id = Zahlungsoption; previous_value = is_active; dropped analog */
case PaymentOption = 'payment_option';
/** Stammdatenfeld am Überlebenden aus einer wegfallenden Firma gefüllt; field = Spalte, previous_value = alter Wert */
case Field = 'field';
/** Die wegfallende Firma selbst (Soft Delete), subject_id = from_company_id */
case Company = 'company';
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Enums;
/**
* Über welchen Weg eine Bildrechte-Bestätigung abgegeben wurde
* (Bildkennzeichnung A.2). `admin` ist kein Bestätigungskanal: Ein
* Admin-Upload wartet auf die Kundenbestätigung per Magic-Link (E10).
*/
enum ConfirmationChannel: string
{
case Form = 'form';
case Api = 'api';
case MagicLink = 'magic_link';
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Enums;
/**
* Stufen der Konversionsleiter (Roadmap Block 0).
*
* Bewusst eine Leiter und keine Liste gleichrangiger Punkte: Vier beliebige
* Konversionspunkte sagen nicht, WO es hakt. Erst die Stufenfolge macht den
* Abbruch lesbar — jede Stufe hat eine eigene Diagnose.
*
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
*/
enum ConversionEventType: string
{
case MailLinkClicked = 'mail_link_clicked';
case MagicLinkConsumed = 'magic_link_consumed';
case NewsroomEdited = 'newsroom_edited';
case AccountCreated = 'account_created';
case FirstBooking = 'first_booking';
/**
* Position in der Leiter. Bestimmt die Reihenfolge im Trichter.
*/
public function step(): int
{
return match ($this) {
self::MailLinkClicked => 1,
self::MagicLinkConsumed => 2,
self::NewsroomEdited => 3,
self::AccountCreated => 4,
self::FirstBooking => 5,
};
}
public function label(): string
{
return match ($this) {
self::MailLinkClicked => 'Mail-Link geklickt',
self::MagicLinkConsumed => 'Magic Link eingelöst',
self::NewsroomEdited => 'Newsroom bearbeitet',
self::AccountCreated => 'Konto angelegt',
self::FirstBooking => 'Erste Buchung',
};
}
/**
* Was ein Abbruch auf dieser Stufe bedeutet — die Diagnose, wegen der die
* Leiter überhaupt als Leiter geführt wird.
*/
public function diagnosis(): string
{
return match ($this) {
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
self::FirstBooking => 'Preis oder Angebot passt nicht',
};
}
/**
* Die Leiter in ihrer Reihenfolge.
*
* @return list<self>
*/
public static function ladder(): array
{
$cases = self::cases();
usort($cases, static fn (self $a, self $b): int => $a->step() <=> $b->step());
return $cases;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Enums;
/**
* Herkunft eines Konversionsereignisses (Roadmap Block 0).
*
* Der entscheidende Punkt: Der Wert wird auf dem MAGIC-LINK-TOKEN gespeichert,
* nicht in einer Session. Die zu messende Strecke führt über zwei Domains
* (presseecho.de → pressekonto.com), einen Mailclient und mögliche
* Weiterleitungen. Eine Session überlebt das nicht, ein Token schon. Ohne
* diesen Schritt wüsste man am Ende, dass jemand gebucht hat, aber nicht, ob er
* aus einer Ablehnungsmail kam — und genau das ist die Frage.
*/
enum ConversionSource: string
{
case MailPublish = 'mail_publish';
case MailReject = 'mail_reject';
/** Hinweis „ein Mensch prüft jetzt" nach erschöpfter Automatik (Roadmap 1d, E3). */
case MailReview = 'mail_review';
case ProfileClaim = 'profile_claim';
/** „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, E10). */
case MailImageConfirmation = 'mail_image_confirmation';
case Organic = 'organic';
public function label(): string
{
return match ($this) {
self::MailPublish => 'Veröffentlichungsmail',
self::MailReject => 'Ablehnungsmail',
self::MailReview => 'Prüfhinweis-Mail',
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
self::MailImageConfirmation => 'Mail „Bild bestätigen"',
self::Organic => 'ohne zugeordnete Herkunft',
};
}
/**
* Auflösung eines Werts aus einer URL oder einem Token.
*
* Unbekannte Werte werden zu `organic` statt zu einem Fehler: Der Parameter
* steht in fremder Hand (Mailclient, Weiterleitung, manuell getippt) und
* darf niemals einen Request scheitern lassen.
*/
public static function parse(?string $value): self
{
return self::tryFrom((string) $value) ?? self::Organic;
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
/**
* Woher der Vorschlag für den Bildnachweis kam, den der Einreicher
* übernommen oder geändert hat (Bildkennzeichnung A.1).
*/
enum CreditSource: string
{
case Customer = 'customer';
case Metadata = 'metadata';
case Api = 'api';
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Enums;
/**
* Art eines Rückläufers. Maßgeblich ist bei DSNs `Action`, nicht der
* Statuscode allein (Konzept `bounce-mails-2026-09-15.md`).
*/
enum EmailBounceKind: string
{
/** `Action: failed`, Status 5.x.x — Postfach existiert nicht, sofort sperren */
case Hard = 'hard';
/** `Action: delayed` oder Status 4.x.x — zählen, ab dem dritten in sieben Tagen sperren */
case Soft = 'soft';
/** Relay lehnt UNS ab (5.7.x, 554 Spam-Pattern, Verbindung) — kein Empfängerproblem, Circuit Breaker */
case Relay = 'relay';
/**
* Der Empfängerserver lehnt aus Richtlinie ab (5.7.x: „Relay access
* denied", Spamfilter, Transportregel). Das Postfach kann einwandfrei
* existieren — wird protokolliert und angezeigt, zählt aber auf keinen
* Schwellwert. Sonst sperrt ein dreimal zuschlagender Firmenfilter eine
* funktionierende Adresse.
*/
case Policy = 'policy';
public function label(): string
{
return match ($this) {
self::Hard => 'hart',
self::Soft => 'weich',
self::Relay => 'Relay',
self::Policy => 'Richtlinie',
};
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Enums;
/**
* Ergebnis der DNS-Prüfung einer E-Mail-Domain (Kontakterreichbarkeit,
* Abschnitt 3). Bewertet ausschließlich den Zustellweg der Domain — nie das
* einzelne Postfach und nie die Identität des Kontakts.
*/
enum EmailDomainState: string
{
/** MX vorhanden und mindestens ein Ziel auflösbar. */
case Deliverable = 'deliverable';
/** Kein MX, aber A/AAAA — impliziter MX nach RFC 5321 § 5.1, zulässig. */
case ImplicitMx = 'implicit_mx';
/** `MX 0 .` — die Domain erklärt ausdrücklich, keine Mail anzunehmen (RFC 7505). */
case NullMx = 'null_mx';
/** Domain existiert, hat aber weder MX noch A/AAAA. */
case NoMailPath = 'no_mail_path';
/** Die Domain gibt es nicht (NXDOMAIN). */
case DomainMissing = 'domain_missing';
/** MX vorhanden, aber kein Ziel auflösbar — fehlerhafte Mailkonfiguration. */
case BrokenMx = 'broken_mx';
/** Timeout/SERVFAIL — Ergebnis unbekannt, erneut prüfen. Kein Befund. */
case Unresolved = 'unresolved';
public function isDeliverable(): bool
{
return $this === self::Deliverable || $this === self::ImplicitMx;
}
/**
* Nachweislich kein Zustellweg. `Unresolved` gehört ausdrücklich nicht
* dazu — „nicht geprüft" ist kein Befund.
*/
public function isBroken(): bool
{
return match ($this) {
self::NullMx, self::NoMailPath, self::DomainMissing, self::BrokenMx => true,
default => false,
};
}
public function label(): string
{
return match ($this) {
self::Deliverable => 'Zustellweg vorhanden',
self::ImplicitMx => 'Zustellweg über A-Eintrag',
self::NullMx => 'Domain nimmt keine Mail an',
self::NoMailPath => 'Kein Zustellweg im DNS',
self::DomainMissing => 'Domain existiert nicht',
self::BrokenMx => 'Mailserver nicht auflösbar',
self::Unresolved => 'Prüfung offen',
};
}
public function badgeClass(): string
{
return match ($this) {
self::Deliverable, self::ImplicitMx => 'ok',
self::Unresolved => 'warn',
default => 'err',
};
}
}

View file

@ -38,7 +38,7 @@ enum EmailNotificationType: string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.',
self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde (inklusive Ablehnungsgrund) oder zur persönlichen Prüfung zurückgehalten wird.',
self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.',
self::PressReleaseCorrected => 'Bestätigung, wenn eine Korrektur oder kostenpflichtige Änderung an einer veröffentlichten Pressemitteilung übernommen wurde.',
self::Newsletter => 'Gelegentliche Informationen zur Plattform: Neuigkeiten, neue Funktionen und wichtige Hinweise zu Ihrem Konto.',
@ -52,7 +52,7 @@ enum EmailNotificationType: string
{
return match ($this) {
self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über das Prüfergebnis Ihrer eingereichten Pressemitteilungen informiert.',
self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
self::PressReleaseCorrected => 'Sie haben die Korrektur bzw. Änderung dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.',
self::Newsletter => 'Sie haben ein Benutzerkonto bei pressekonto bzw. den Portalen presseecho und businessportal24 und erhalten daher gelegentlich Informationen zur Plattform.',
@ -66,7 +66,7 @@ enum EmailNotificationType: string
{
return match ($this) {
self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen',
self::PressReleaseRejected => 'Benachrichtigungen über Ablehnung und Prüfung Ihrer Pressemitteilungen',
self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen',
self::PressReleaseCorrected => 'Bestätigungen über Korrekturen und Änderungen Ihrer Pressemitteilungen',
self::Newsletter => 'Newsletter und Plattform-Informationen',

View file

@ -0,0 +1,29 @@
<?php
namespace App\Enums;
enum EmailSuppressionReason: string
{
case HardBounce = 'hard_bounce';
case SoftBounces = 'soft_bounces';
case Manual = 'manual';
/**
* Abmeldung durch den Empfänger selbst (Roadmap 1d.3) — kein
* Zustellproblem. Bleibt für Benachrichtigungen wirksam, lässt aber
* durch, was die Person selbst anfordert (Anmeldelink).
*/
case Unsubscribed = 'unsubscribed';
public function label(): string
{
return match ($this) {
self::HardBounce => 'Postfach existiert nicht',
self::SoftBounces => 'wiederholt unzustellbar',
self::Manual => 'manuell gesperrt',
self::Unsubscribed => 'abgemeldet',
};
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Enums;
/**
* Kategorien des KI-Vorab-Checks mit ihrem Standardausgang (Decision-Update
* 12.3 und 12.4). Die Ablehnungskategorien können per Konfiguration auf die
* Queue herabgestuft werden (12.8), nie abgeschaltet.
*/
enum ImagePrecheckCategory: string
{
case StockWatermark = 'stock_watermark';
case NudityOrViolence = 'nudity_or_violence';
case DocumentOrId = 'document_or_id';
case ScreenshotOrCollage = 'screenshot_or_collage';
case ThirdPartyLogoProminent = 'third_party_logo_prominent';
case LicensePlateReadable = 'license_plate_readable';
case PersonsVisible = 'persons_visible';
case LooksAiGenerated = 'looks_ai_generated';
case TechnicalDefect = 'technical_defect';
public function defaultOutcome(): ImagePrecheckOutcome
{
return match ($this) {
self::StockWatermark, self::NudityOrViolence, self::DocumentOrId => ImagePrecheckOutcome::Reject,
self::ScreenshotOrCollage, self::ThirdPartyLogoProminent, self::LicensePlateReadable => ImagePrecheckOutcome::Queue,
self::PersonsVisible, self::LooksAiGenerated, self::TechnicalDefect => ImagePrecheckOutcome::Hint,
};
}
/** Stufe 1 (DU 12.7) wertet nur das Stock-Wasserzeichen aus. */
public function activeInStage(int $stage): bool
{
return $stage >= 2 || $this === self::StockWatermark;
}
public function label(): string
{
return match ($this) {
self::StockWatermark => 'Wasserzeichen einer Bildagentur',
self::NudityOrViolence => 'Nacktheit oder Gewalt',
self::DocumentOrId => 'Dokument oder Ausweis',
self::ScreenshotOrCollage => 'Screenshot oder Collage',
self::ThirdPartyLogoProminent => 'Fremdes Logo prominent',
self::LicensePlateReadable => 'Kfz-Kennzeichen lesbar',
self::PersonsVisible => 'Personen erkennbar',
self::LooksAiGenerated => 'Wirkt KI-generiert',
self::TechnicalDefect => 'Technischer Mangel',
};
}
/**
* Satz für den Einreicher bei automatischer Ablehnung (DU 12.3: „Grund in
* einem Satz, Bild neu hochladen"). Nur für Ablehnungskategorien belegt.
*/
public function rejectionMessage(): string
{
return match ($this) {
self::StockWatermark => __('Das Bild trägt das Wasserzeichen einer Bildagentur und kann so nicht verwendet werden. Bitte laden Sie eine lizenzierte Fassung ohne Wasserzeichen hoch.'),
self::NudityOrViolence => __('Das Bild zeigt Inhalte, die wir nicht veröffentlichen (Nacktheit oder Gewalt). Bitte wählen Sie ein anderes Bild.'),
self::DocumentOrId => __('Das Bild zeigt ein Dokument oder einen Ausweis. Bitte wählen Sie ein anderes Bild.'),
default => __('Das Bild kann so nicht verwendet werden. Bitte wählen Sie ein anderes Bild.'),
};
}
/** Hinweis im Formular (DU 12.3), ohne Eingriff. */
public function hintMessage(?string $note = null): string
{
return match ($this) {
self::PersonsVisible => __('Auf dem Bild sind Personen zu sehen. Mit Ihrer Bestätigung erklären Sie, dass sie mit der Veröffentlichung einverstanden sind.'),
self::LooksAiGenerated => __('Bitte prüfen Sie Ihre Angabe zur KI-Kennzeichnung.'),
self::TechnicalDefect => $note !== null && $note !== ''
? __('Das Bild könnte technisch mangelhaft sein: :note', ['note' => $note])
: __('Das Bild könnte technisch mangelhaft sein (zu klein, verzerrt oder Platzhalter).'),
default => $this->label(),
};
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Enums;
/**
* Ausgang des KI-Vorab-Checks (Decision-Update 12.3). Jeder Befund hat
* genau einen definierten Ausgang; kein Befund liegt nur im Log.
*/
enum ImagePrecheckOutcome: string
{
case Pass = 'pass';
case Hint = 'hint';
case Queue = 'queue';
case Reject = 'reject';
case Error = 'error';
public function label(): string
{
return match ($this) {
self::Pass => 'ohne Befund',
self::Hint => 'Hinweis an den Einreicher',
self::Queue => 'Sichtung durch den Admin',
self::Reject => 'automatisch abgelehnt',
self::Error => 'nicht geprüft (Fehler)',
};
}
public function rank(): int
{
return match ($this) {
self::Pass => 0,
self::Error => 1,
self::Hint => 2,
self::Queue => 3,
self::Reject => 4,
};
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Enums;
/**
* Ergebnisklasse der Herkunftsprüfung eines Bild-Uploads (Bildkennzeichnung
* A.4, Feedback F02). Phase 1 liest nur ungesicherte IPTC/XMP/EXIF-Angaben,
* kann also höchstens `unsigned_metadata` liefern. Die `validated_*`-Stufen
* entstehen erst mit der C2PA-Signaturprüfung (Phase 2, Block I).
*/
enum ImageProvenanceStatus: string
{
/** Keine unterstützten Herkunftsdaten in der Datei. */
case Absent = 'absent';
/** IPTC/XMP/EXIF gefunden — frei editierbar, nicht signiert. */
case UnsignedMetadata = 'unsigned_metadata';
/** C2PA-Manifest, Signatur und Dateibindung geprüft, Aussteller in der Vertrauensliste. */
case ValidatedTrusted = 'validated_trusted';
/** C2PA technisch geprüft, Aussteller nicht in der Vertrauensliste. */
case ValidatedUntrusted = 'validated_untrusted';
/** C2PA vorhanden, Prüfung fehlgeschlagen (Signatur oder Bindung). */
case Invalid = 'invalid';
/** Format oder Manifestversion nicht unterstützt. */
case Unsupported = 'unsupported';
/** Technischer Fehler oder Zeitüberschreitung — Prüfung nicht abgeschlossen. */
case Error = 'error';
/**
* Nur ein vertrauenswürdig geprüfter Befund darf eine Kennzeichnung
* erzwingen (Decision-Update E9).
*/
public function allowsLock(): bool
{
return $this === self::ValidatedTrusted;
}
/**
* Ob überhaupt Herkunftsangaben vorliegen, aus denen das Formular
* vorbelegt werden kann.
*/
public function hasMetadata(): bool
{
return in_array($this, [self::UnsignedMetadata, self::ValidatedTrusted, self::ValidatedUntrusted], true);
}
}

62
app/Enums/IndexState.php Normal file
View file

@ -0,0 +1,62 @@
<?php
namespace App\Enums;
/**
* Befund einer URL-Inspektion, auf das reduziert, was in jeder Anzeige
* unterschieden werden muss (Anhang 17.09.2026, 1c.4): „noch nicht
* gecrawlt" und „gecrawlt, aber nicht indexiert" sind verschiedene Befunde.
*/
enum IndexState: string
{
case Indexed = 'indexed';
case CrawledNotIndexed = 'crawled_not_indexed';
case NotCrawled = 'not_crawled';
case Excluded = 'excluded';
case Unknown = 'unknown';
/**
* @param array<string, mixed> $indexStatus `inspectionResult.indexStatusResult` der URL Inspection API
*/
public static function fromIndexStatus(array $indexStatus): self
{
$verdict = strtoupper((string) ($indexStatus['verdict'] ?? ''));
$coverage = mb_strtolower((string) ($indexStatus['coverageState'] ?? ''));
$indexing = strtoupper((string) ($indexStatus['indexingState'] ?? ''));
$robots = strtoupper((string) ($indexStatus['robotsTxtState'] ?? ''));
$fetch = strtoupper((string) ($indexStatus['pageFetchState'] ?? ''));
$crawled = ! empty($indexStatus['lastCrawlTime']);
if ($verdict === 'PASS' || (str_contains($coverage, 'indexed') && ! str_contains($coverage, 'not indexed'))) {
return self::Indexed;
}
if ($robots === 'DISALLOWED'
|| in_array($indexing, ['BLOCKED_BY_META_TAG', 'BLOCKED_BY_HTTP_HEADER', 'BLOCKED_BY_ROBOTS_TXT'], true)
|| ($fetch !== '' && $fetch !== 'SUCCESSFUL' && $crawled)
|| str_contains($coverage, 'duplicate')
|| str_contains($coverage, 'canonical')
|| str_contains($coverage, 'redirect')
|| str_contains($coverage, 'noindex')
|| str_contains($coverage, 'blocked')) {
return self::Excluded;
}
if (! $crawled) {
return $verdict === '' && $coverage === '' ? self::Unknown : self::NotCrawled;
}
return self::CrawledNotIndexed;
}
public function label(): string
{
return match ($this) {
self::Indexed => 'indexiert',
self::CrawledNotIndexed => 'gecrawlt, nicht indexiert',
self::NotCrawled => 'noch nicht gecrawlt',
self::Excluded => 'ausgeschlossen',
self::Unknown => 'unbekannt',
};
}
}

View file

@ -4,23 +4,33 @@ namespace App\Enums;
/**
* Versandstatus eines einzelnen Newsletter-Empfängers (Protokoll).
*
* Sending ist der Claim des Workers: Bleibt eine Zeile dauerhaft darin,
* ist der Worker zwischen Mailserver-Übergabe und Protokollierung
* abgestürzt — die Zustellung ist dann unklar; `newsletter:resume`
* setzt solche Zeilen bewusst zurück.
* SkippedOptOut greift, wenn sich der User zwischen Versandstart und
* tatsächlichem Versand im Worker abgemeldet hat.
* tatsächlichem Versand im Worker abgemeldet hat. Suppressed, wenn das
* Versand-Gate die Adresse wegen der Sperrliste gestrichen hat.
*/
enum NewsletterRecipientStatus: string
{
case Pending = 'pending';
case Sending = 'sending';
case Sent = 'sent';
case Failed = 'failed';
case SkippedOptOut = 'skipped_opt_out';
case Suppressed = 'suppressed';
public function label(): string
{
return match ($this) {
self::Pending => 'Ausstehend',
self::Sending => 'Wird versendet',
self::Sent => 'Versendet',
self::Failed => 'Fehlgeschlagen',
self::SkippedOptOut => 'Übersprungen (abgemeldet)',
self::Suppressed => 'Gesperrt (Sperrliste)',
};
}
@ -31,9 +41,22 @@ enum NewsletterRecipientStatus: string
{
return match ($this) {
self::Pending => 'zinc',
self::Sending => 'blue',
self::Sent => 'green',
self::Failed => 'red',
self::SkippedOptOut => 'amber',
self::Suppressed => 'orange',
};
}
/**
* Status, in denen der Versand noch aussteht oder läuft — solange eine
* Kampagne solche Zeilen hat, ist sie nicht abgeschlossen.
*
* @return list<string>
*/
public static function openValues(): array
{
return [self::Pending->value, self::Sending->value];
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der Circuit Breaker hat den Versand angehalten. Im Queue-Worker lässt die
* Ausnahme den Job fehlschlagen, damit er sich später nachholen lässt —
* ein still verworfener Magic Link wäre verloren.
*/
class MailDeliveryPausedException extends RuntimeException {}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Eine Aktion auf einer MAN-Rechnung (senden, erinnern, bezahlt, beenden)
* ist im aktuellen Zustand nicht zulässig. Die Meldung ist für die
* Admin-Oberfläche formuliert.
*/
class ManualInvoiceActionException extends RuntimeException {}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ArchiveEntry;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Öffnen und Export von Archiveinträgen (Beweisarchiv F.2): GET zeigt ein
* kleines Formular, erst der POST mit Zweck (CSRF) liefert den Download,
* und der Zugriff steht im Protokoll. Der Zweck steht nie in der URL und
* damit nicht in Server-Logs (Umsetzungsaudit 29.09.2026, A-08).
*/
class ArchiveEntryController extends Controller
{
public function __construct(private readonly EvidenceArchive $archive) {}
public function download(Request $request, ArchiveEntry $entry): Response|View
{
return $this->serve($request, $entry, 'download');
}
public function export(Request $request, ArchiveEntry $entry): Response|View
{
return $this->serve($request, $entry, 'export');
}
private function serve(Request $request, ArchiveEntry $entry, string $action): Response|View
{
abort_if($entry->isTombstoned(), 410, 'Der Eintrag ist nach Fristablauf gelöscht (Tombstone).');
$purpose = $request->isMethod('post') ? trim((string) $request->request->get('purpose', '')) : '';
if ($purpose === '') {
return view('admin.archive.purpose', [
'formAction' => route($action === 'export' ? 'admin.archive.export' : 'admin.archive.download', ['entry' => $entry->id]),
'description' => __('Archiveintrag #:id (:event). Jeder Zugriff auf das Beweisarchiv wird mit Zweck protokolliert.', ['id' => $entry->id, 'event' => $entry->event->label()]),
'submitLabel' => $action === 'export' ? __('Export herunterladen') : __('Payload öffnen'),
'cancelUrl' => route('admin.archive.index'),
]);
}
$bytes = $action === 'export'
? $this->archive->export($entry, $request->user(), $purpose)
: $this->archive->open($entry, $request->user(), $purpose);
$name = sprintf('beweisarchiv-%d-%s.zip', $entry->getKey(), $action === 'export' ? 'export' : 'payload');
return response($bytes, 200, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="'.$name.'"',
'Cache-Control' => 'private, no-store',
'X-Content-Type-Options' => 'nosniff',
]);
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Authorization\Permission;
use App\Enums\ActivityType;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Einziger Zugriffsweg auf Beweisoriginal und veröffentlichte Fassung
* (Bildkennzeichnung E.4): nur Admins, nur mit angegebenem Zweck, jeder
* Abruf im Aktivitätsverlauf. Medienroute, Vorschau-Signaturen und API
* kennen diese Pfade nicht. Block F verlagert das Protokoll in
* `archive_access_log`.
*
* Umsetzungsaudit 29.09.2026: Der Zweck kommt nur per POST (A-08), GET
* zeigt das Zweckformular. Restnachweise entfernter Bilder (weich
* gelöschte Zeile) nur mit `compliance:manage` (A-11) — wie das Archiv.
*/
class PressReleaseImageEvidenceController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function original(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'original');
}
public function published(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'published');
}
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse|View
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
if ($image->trashed()) {
Gate::authorize(Permission::ComplianceManage);
}
$purpose = $request->isMethod('post') ? trim((string) $request->request->get('purpose', '')) : '';
if ($purpose === '') {
abort_if($request->isMethod('post'), 422, 'Zweck des Zugriffs fehlt (purpose).');
return view('admin.archive.purpose', [
'formAction' => route($kind === 'original' ? 'admin.press-releases.images.original' : 'admin.press-releases.images.published', ['id' => $pressRelease->getKey(), 'image' => $image->getKey()]),
'description' => __(':kind zu Bild #:image (PM #:pr). Jeder Abruf wird mit Zweck protokolliert.', [
'kind' => $kind === 'original' ? __('Beweisoriginal') : __('Veröffentlichte Fassung'),
'image' => $image->getKey(),
'pr' => $pressRelease->getKey(),
]),
'submitLabel' => __('Datei öffnen'),
'cancelUrl' => route('admin.press-releases.show', ['id' => $pressRelease->getKey()]),
]);
}
$path = $kind === 'original' ? $image->original_path : $image->published_snapshot_path;
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
abort_unless(is_string($path) && $path !== '' && $disk->exists($path), 404);
$this->activityLog->record(
ActivityType::EvidenceAccess,
sprintf('%s zu Bild #%d (PM #%d) abgerufen: %s', $kind === 'original' ? 'Beweisoriginal' : 'Veröffentlichte Fassung', $image->getKey(), $pressRelease->getKey(), $purpose),
$request->user(),
$image,
['kind' => $kind, 'purpose' => $purpose, 'sha256' => $kind === 'original' ? $image->original_sha256 : $image->published_snapshot_sha256],
);
return $disk->response($path, basename($path), [
'Cache-Control' => 'private, no-store',
'X-Content-Type-Options' => 'nosniff',
]);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Enums\Portal;
use App\Services\CurrentPortalContext;
/**
* Löst das Ziel-Portal eines API-Schreibzugriffs auf.
*
* Reihenfolge (Entscheidung 13.08.2026, API-Portal-Fix):
* 1. Explizit mitgesendeter portal-Parameter (überstimmt alles).
* 2. Domain des Requests (CurrentPortalContext, gesetzt via SetCurrentPortal:
* presseecho.de → presseecho, businessportal24.de → businessportal24).
* 3. Aufrufer-spezifischer Fallback (Portal der Firma bzw. des Users) —
* greift z. B. bei Calls über die Portal-/Admin-Domain ohne Parameter.
* 4. `Portal::Both` als letzter Ausweg.
*
* Stufe 4 macht die Rückgabe bewusst nicht-nullbar: `press_releases.portal` und
* `companies.portal` sind NOT-NULL-ENUMs mit genau diesem Default. Ein `null`
* aus dieser Methode landete also als SQL-Fehler und damit als 500er beim
* Alt-Client, statt als verwertbare Antwort — und die Aufrufer, die das
* Ergebnis direkt mit `->value` weiterreichen, wären ein Fatal Error.
*/
trait ResolvesRequestedPortal
{
private function resolveRequestedPortal(?string $requestedPortal, ?Portal $fallback): Portal
{
return Portal::tryFrom((string) $requestedPortal)
?? CurrentPortalContext::get()
?? $fallback
?? Portal::Both;
}
}

View file

@ -2,11 +2,19 @@
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\ActivityType;
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Services\ActivityLog;
use App\Services\Company\ApiNewsroomAssignment;
use App\Services\Company\CompanyNameNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
@ -16,9 +24,9 @@ use Illuminate\Validation\ValidationException;
*/
class LegacyCompanyController extends Controller
{
use RespondsLikeLegacyApi;
use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public function store(Request $request): JsonResponse
public function store(Request $request, ApiNewsroomAssignment $assignment, ActivityLog $activityLog): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
@ -26,11 +34,12 @@ class LegacyCompanyController extends Controller
$validated = $request->validate([
'company' => ['required', 'array'],
'company.name' => ['required', 'string', 'max:255'],
'company.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'company.address' => ['nullable', 'string', 'max:1000'],
'company.country_id' => ['nullable', 'integer'],
'company.phone' => ['nullable', 'string', 'max:100'],
'company.fax' => ['nullable', 'string', 'max:100'],
'company.email' => ['nullable', 'email', 'max:255'],
'company.email' => ['nullable', 'email', 'max:255', new PlausibleEmailDomain],
'company.website' => ['nullable', 'string', 'max:255'],
])['company'];
} catch (ValidationException $exception) {
@ -39,8 +48,30 @@ class LegacyCompanyController extends Controller
$user = $request->user();
// Portal aus Parameter/Domain, sonst wie bisher vom User — der
// User-Fallback allein ließ alle Firmen (und damit deren PMs) von
// Kunden mit Keys beider Altportale im selben Portal landen.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $user->portal);
// Links gehören nicht in den Namen (17.09.2026): Einlieferer schicken
// „Firma - https://…" — die URL wandert ins Website-Feld, der Name
// bleibt sauber. Vor der Zuordnung, damit der bereinigte Name mit dem
// ebenso bereinigten Bestand verglichen wird.
$normalized = CompanyNameNormalizer::normalize($validated['name'], $validated['website'] ?? null);
$validated['name'] = $normalized['name'];
$validated['website'] = $normalized['website'];
// Steht unter dieser Firmen-E-Mail schon dieselbe Firma, bekommt der
// Aufrufer deren ID — statt je Meldung eines neuen Newsrooms
// (Roadmap 1a.4). Der Vertrag bleibt: 201 + Location.
$existing = $assignment->assign($user, $validated['email'] ?? null, $validated['name'], $portal);
if ($existing) {
return $this->legacyCreated(url("/service/users/me/companies/{$existing->id}"));
}
$company = new Company([
'portal' => $user->portal?->value,
'portal' => $portal->value,
'type' => CompanyType::Company->value,
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
@ -57,6 +88,16 @@ class LegacyCompanyController extends Controller
$company->save();
$company->users()->attach($user->id, ['role' => 'owner']);
if ($normalized['removed'] !== []) {
$activityLog->record(
ActivityType::Correction,
sprintf('API-Firma #%d: Link aus dem Namen entfernt („%s“ → „%s“)', $company->getKey(), $request->input('company.name'), $company->name),
$user,
$company,
['removed' => $normalized['removed']],
);
}
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}"));
}
}

View file

@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Scopes\PortalScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@ -21,7 +23,7 @@ class LegacyContactController extends Controller
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();
@ -40,7 +42,7 @@ class LegacyContactController extends Controller
'contact.responsibility' => ['nullable', 'string', 'max:255'],
'contact.phone' => ['nullable', 'string', 'max:100'],
'contact.fax' => ['nullable', 'string', 'max:100'],
'contact.email' => ['nullable', 'email', 'max:255'],
'contact.email' => ['nullable', 'email', 'max:255', new PlausibleEmailDomain],
])['contact'];
} catch (ValidationException $exception) {
return $this->legacyValidationError($exception);

View file

@ -3,7 +3,9 @@
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Models\AppSetting;
use App\Models\Category;
@ -18,6 +20,7 @@ use App\Services\PressRelease\QuotaExceededException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use function Illuminate\Support\defer;
@ -36,7 +39,7 @@ use function Illuminate\Support\defer;
*/
class LegacyPressReleaseController extends Controller
{
use RespondsLikeLegacyApi;
use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public const SETTING_AI_REVIEW = 'legacy_api_ai_review';
@ -53,6 +56,7 @@ class LegacyPressReleaseController extends Controller
'press_release.company_id' => ['required', 'integer'],
'press_release.category_id' => ['required', 'integer'],
'press_release.language' => ['nullable', 'string', 'max:5'],
'press_release.portal' => ['nullable', Rule::enum(Portal::class)->only([Portal::Presseecho, Portal::Businessportal24])],
'press_release.title' => ['required', 'string', 'max:255'],
'press_release.text' => ['required', 'string'],
'press_release.backlink_url' => ['nullable', 'url', 'max:255'],
@ -66,7 +70,7 @@ class LegacyPressReleaseController extends Controller
$user = $request->user();
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey((int) $validated['company_id'])
->accessibleBy($user)
->first();
@ -77,8 +81,8 @@ class LegacyPressReleaseController extends Controller
// Alt-Clients senden Legacy-Kategorie-IDs; nach der Migration sind
// id und legacy_id identisch — legacy_id zuerst, id als Fallback.
$category = Category::withoutGlobalScopes()->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScopes()->find((int) $validated['category_id']);
$category = Category::withoutGlobalScope(PortalScope::class)->where('legacy_id', (int) $validated['category_id'])->first()
?? Category::withoutGlobalScope(PortalScope::class)->find((int) $validated['category_id']);
if (! $category) {
return $this->legacyError(422, ['Unknown category.']);
@ -104,7 +108,12 @@ class LegacyPressReleaseController extends Controller
]);
$pressRelease->uuid = (string) Str::uuid();
$pressRelease->user_id = $user->id;
$pressRelease->portal = $company->portal?->value;
// Firmen können seit 1a.3 `both` sein — das ist kein Ziel-Portal für
// eine Meldung. Dann entscheidet das Portal des API-Nutzers.
$pressRelease->portal = $this->resolveRequestedPortal(
$validated['portal'] ?? null,
$company->portal === Portal::Both ? $user->portal : $company->portal,
);
$pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal,
@ -178,7 +187,9 @@ class LegacyPressReleaseController extends Controller
'teaser_end' => $pressRelease->teaser_end,
'language' => $pressRelease->language,
'category_id' => $pressRelease->category_id,
'view_url' => "{$spokeUrl}/de/press-release/{$pressRelease->slug}",
// Englische Meldungen liegen unter /en/ (die Detailseite filtert
// nach Sprache); /de/ würde für sie 404 liefern.
'view_url' => $spokeUrl.'/'.($pressRelease->language === 'en' ? 'en' : 'de').'/press-release/'.$pressRelease->slug,
];
if ($pressRelease->company) {

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Legacy;
use App\Enums\AiImageDeclaration;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -24,9 +25,18 @@ class LegacyPressReleaseImageController extends Controller
public function store(Request $request, int $pressRelease): JsonResponse
{
// Bildkennzeichnung E-B4 (22.09.2026): Der Alt-Upload kennt weder
// Bildnachweis noch Rechtebestätigung und wird nicht genutzt — mit
// Formular v2 abgeschaltet. Wer Bilder liefern will, nimmt v1.
if (config('images.rights_v2')) {
return $this->legacyError(410, [
'Image upload via the legacy service API has been discontinued. Use POST /api/v1/press-releases/{id}/images with copyright, ai_declaration, rights_confirmed and confirm_text_version.',
]);
}
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
$pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($pressRelease);
if (! $pressRelease) {
return $this->legacyError(404, [sprintf('Object press_release does not exist (%s).', $request->route('pressRelease'))]);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Scopes\PortalScope;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -14,10 +15,16 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$companies = Company::withoutGlobalScopes()
// Untergrenze wie Obergrenze (Audit F11): ein negatives per_page hob
// das SQL-Limit auf.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);
$companies = Company::withoutGlobalScope(PortalScope::class)
->accessibleBy($request->user())
->orderBy('name')
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return CompanyResource::collection($companies);
}
@ -31,7 +38,7 @@ class CompanyController extends Controller
{
abort_unless($request->user()->tokenCan('companies:read'), 403);
$company = Company::withoutGlobalScopes()
$company = Company::withoutGlobalScope(PortalScope::class)
->whereKey($company)
->accessibleBy($request->user())
->first();

View file

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Zustimmungsgate per API (Block G, O2): einmal je Kontoinhaber und
* Dokumentversion. Ohne gültige Annahme antwortet POST …/submit mit 428
* und den geforderten Fassungen.
*/
class LegalAcceptanceController extends Controller
{
public function __construct(private readonly LegalAcceptanceGate $gate) {}
public function index(Request $request): JsonResponse
{
$required = $this->gate->requiredFor($request->user())->map(fn (LegalDocument $d) => $this->gate->describe($d))->values();
return response()->json([
'gate_enabled' => $this->gate->isEnabled(),
'required' => $required,
'accepted' => LegalDocumentAcceptance::query()
->where('user_id', $request->user()->getKey())
->orderByDesc('id')
->limit(20)
->get(['type', 'version', 'context', 'accepted_at'])
->map(fn (LegalDocumentAcceptance $a) => ['type' => $a->type, 'version' => $a->version, 'context' => $a->context, 'accepted_at' => $a->accepted_at->toIso8601String()])
->values(),
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$validated = $request->validate([
'documents' => ['required', 'array', 'min:1'],
'documents.*.type' => ['required', Rule::in(LegalAcceptanceGate::TYPES)],
'documents.*.version' => ['required', 'string', 'max:10'],
]);
$documents = [];
foreach ($validated['documents'] as $item) {
$current = LegalDocument::current($item['type']);
if ($current === null || $current->version !== $item['version']) {
return response()->json([
'message' => sprintf('Version %s of %s is not the current one (current: %s).', $item['version'], $item['type'], $current !== null ? $current->version : 'none'),
], 422);
}
$documents[] = $current;
}
$this->gate->accept($request->user(), $documents, LegalDocumentAcceptance::CONTEXT_API, $request->ip(), $request->userAgent());
return $this->index($request)->setStatusCode(201);
}
}

View file

@ -3,13 +3,18 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\AiTextDeclaration;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Legal\LegalAcceptanceGate;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
@ -22,19 +27,30 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class PressReleaseController extends Controller
{
use ResolvesRequestedPortal;
public function index(Request $request): AnonymousResourceCollection
{
abort_unless($request->user()->tokenCan('press-releases:read'), 403);
$pressReleases = PressRelease::withoutGlobalScopes()
// Listenparameter werden validiert statt durchgereicht (Audit F11):
// ein negatives per_page hob das SQL-Limit auf, ein Array als status
// lief in einen TypeError.
$filters = $request->validate([
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'status' => ['sometimes', 'string', Rule::enum(PressReleaseStatus::class)],
]);
$pressReleases = PressRelease::withoutGlobalScope(PortalScope::class)
->where('user_id', $request->user()->id)
->with($this->apiResourceRelations())
->when($request->query('status'), fn ($query, string $status) => $query->where('status', $status))
->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))
->latest()
->paginate(min((int) $request->query('per_page', 25), 100));
->paginate((int) ($filters['per_page'] ?? 25));
return PressReleaseResource::collection($pressReleases);
}
@ -45,11 +61,16 @@ class PressReleaseController extends Controller
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
abort_unless($company !== null, 403);
// Ziel-Portal: expliziter Parameter > Request-Domain > Firmen-Portal.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $company->portal)->value;
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
$pressRelease = new PressRelease([
...$validated,
'uuid' => (string) Str::uuid(),
'user_id' => $request->user()->id,
'portal' => $company->portal->value,
'portal' => $portal,
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
// `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
@ -65,11 +86,11 @@ class PressReleaseController extends Controller
] : []),
]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
'portal' => $company->portal->value,
'portal' => $portal,
'language' => $validated['language'],
]);
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
'portal' => $company->portal->value,
'portal' => $portal,
'language' => $validated['language'],
]);
@ -108,7 +129,21 @@ class PressReleaseController extends Controller
$language = $validated['language'] ?? $pressRelease->language;
$title = $validated['title'] ?? $pressRelease->title;
$portal = $company->portal->value;
// Beim Update verschiebt nur ein expliziter portal-Parameter die PM —
// weder Request-Domain noch Firmen-Portal ändern eine bestehende
// Zuordnung stillschweigend.
$portal = (Portal::tryFrom((string) ($validated['portal'] ?? ''))
?? $pressRelease->portal
?? $company->portal)->value;
// Der resultierende Zustand muss passen (Audit U10): eine neue
// Kategorie gegen das Ziel-Portal, bei reinem Portalwechsel die
// bestehende Kategorie gegen das neue Portal.
if (isset($validated['category_id'])) {
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
} elseif ($portal !== $pressRelease->portal->value) {
$this->assertCategoryFitsPortal((int) $pressRelease->category_id, $portal, $request);
}
$pressRelease->fill([
...$validated,
@ -174,6 +209,18 @@ class PressReleaseController extends Controller
], 409);
}
// Zustimmungsgate (Block G, O2): ohne Annahme der aktuellen AGB- und
// Richtlinien-Fassung 428 mit den geforderten Versionen.
$gate = app(LegalAcceptanceGate::class);
$requiredDocuments = $gate->requiredFor($request->user());
if ($requiredDocuments->isNotEmpty()) {
return response()->json([
'message' => 'The current terms and guidelines must be accepted before submitting (POST /api/v1/legal/acceptances).',
'required_documents' => $requiredDocuments->map(fn ($document) => $gate->describe($document))->values(),
], 428);
}
$pressRelease->update([
'ai_text_declaration' => $validated['ai_text_declaration'],
'ai_declaration_confirmed_at' => now(),
@ -221,9 +268,34 @@ class PressReleaseController extends Controller
return response()->noContent();
}
/**
* Die Kategorie muss aktiv sein und zum Ziel-Portal passen (Audit F11):
* `exists:categories,id` allein ließ inaktive und portalfremde Kategorien
* durch. Legacy-Aliasse (/service/…) lösen ihre Kategorie über legacy_id
* im eigenen Controller auf und bleiben hier außen vor.
*/
private function assertCategoryFitsPortal(int $categoryId, string $portal, Request $request): void
{
if ($this->isLegacyServiceRequest($request)) {
return;
}
$fits = Category::query()
->whereKey($categoryId)
->where('is_active', true)
->whereIn('portal', [$portal, Portal::Both->value])
->exists();
if (! $fits) {
throw ValidationException::withMessages([
'category_id' => 'Die Kategorie ist inaktiv oder gehört nicht zum Ziel-Portal.',
]);
}
}
private function findOwnedCompany(int $companyId, Request $request): ?Company
{
return Company::withoutGlobalScopes()
return Company::withoutGlobalScope(PortalScope::class)
->whereKey($companyId)
->accessibleBy($request->user())
->first();
@ -242,7 +314,7 @@ class PressReleaseController extends Controller
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -9,17 +9,28 @@ use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
use App\Http\Resources\PressReleaseImageResource;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Laravel\Sanctum\PersonalAccessToken;
use RuntimeException;
class PressReleaseImageController extends Controller
{
public function __construct(private readonly ImageService $imageService) {}
public function __construct(
private readonly ImageService $imageService,
private readonly PressReleaseImageUploader $uploader,
private readonly PressReleaseImageRemover $remover,
) {}
public function index(Request $request, int $pressRelease): AnonymousResourceCollection
{
@ -48,6 +59,13 @@ class PressReleaseImageController extends Controller
/** @var UploadedFile $file */
$file = $request->file('image');
// Formular v2 (Bildkennzeichnung Block C): derselbe Ablauf wie im
// Kundenformular — Original, Herkunftsprüfung, Bestätigung mit
// Token-ID. Ein zweites Bild wird abgelehnt (ein Titelbild je PM).
if (config('images.rights_v2')) {
return $this->storeUnderRightsV2($request, $pressRelease, $validated, $file);
}
try {
$stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id);
} catch (RuntimeException $exception) {
@ -64,10 +82,8 @@ class PressReleaseImageController extends Controller
'disk' => $stored['disk'],
'path' => $stored['path'],
'variants' => $stored['variants'],
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest);
// Legacy-/service-Calls ohne Feld gelten als Originalfoto
// (Kompatibilitäts-Default, Entscheidung 10.07.2026).
'ai_declaration' => $validated['ai_declaration'] ?? AiImageDeclaration::Original->value,
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest).
'ai_declaration' => $validated['ai_declaration'],
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,
@ -89,8 +105,8 @@ class PressReleaseImageController extends Controller
$image = PressReleaseImage::query()
->whereKey($pressReleaseImage)
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScopes()->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()])
->whereHas('pressRelease', fn ($query) => $query->withoutGlobalScope(PortalScope::class)->where('user_id', $request->user()->id))
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScope(PortalScope::class)])
->first();
abort_unless($image !== null, 403);
@ -101,15 +117,86 @@ class PressReleaseImageController extends Controller
], 409);
}
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
if (config('images.rights_v2')) {
// Status-Regel unter der Sperre erneut prüfen (Umsetzungsaudit
// 29.09., B-05): Ein gleichzeitiges Einreichen gewinnt.
try {
$this->remover->remove($image, $request->user(), allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
} catch (ImageUploadException) {
return response()->json([
'message' => 'Only draft or rejected press releases may be edited.',
], 409);
}
} else {
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
}
return response()->noContent();
}
/**
* @param array<string, mixed> $validated
*/
private function storeUnderRightsV2(StorePressReleaseImageRequest $request, PressRelease $pressRelease, array $validated, UploadedFile $file): JsonResponse
{
$focus = isset($validated['focus_x'], $validated['focus_y'])
? ['x' => (int) $validated['focus_x'], 'y' => (int) $validated['focus_y']]
: null;
$submission = new ImageSubmission(
copyright: (string) $validated['copyright'],
aiDeclaration: AiImageDeclaration::from($validated['ai_declaration']),
rightsConfirmed: (bool) $validated['rights_confirmed'],
legalTextKey: (string) $validated['confirm_text_version'],
title: $validated['title'] ?? null,
description: $validated['description'] ?? null,
licenseUrl: $validated['license_url'] ?? null,
sourceUrl: $validated['source_url'] ?? null,
focus: $focus,
noCrop: (bool) ($validated['no_crop'] ?? false),
);
// Token-ID fürs Protokoll über den Bearer-Header auflösen: Sanctums
// currentAccessToken() ist im Test ein TransientToken ohne Datensatz;
// ohne Bearer bleibt die ID leer, der Kontoinhaber steht trotzdem drin.
$bearer = $request->bearerToken();
$token = $bearer !== null ? PersonalAccessToken::findToken($bearer) : null;
$actor = ImageUploadActor::api(
$request->user(),
$token !== null ? (int) $token->getKey() : null,
$request->ip(),
$request->userAgent(),
);
try {
$image = $this->uploader->store(
$file,
$pressRelease,
$submission,
$actor,
allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
);
} catch (ImageUploadException $exception) {
return response()->json([
'message' => $exception->getMessage(),
'errors' => [$exception->field => [$exception->getMessage()]],
], 422);
} catch (RuntimeException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 422);
}
return PressReleaseImageResource::make($image->load('latestRightsConfirmation'))
->response()
->setStatusCode(201);
}
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScopes()
return PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressReleaseId)
->ownedByApiUser($request->user())
->first();

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -14,7 +15,7 @@ class ConfirmEmailChangeController extends Controller
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
*/
public function __invoke(Request $request, User $user, string $hash): RedirectResponse
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
{
abort_unless($request->user()->is($user), 403);
@ -39,6 +40,11 @@ class ConfirmEmailChangeController extends Controller
'pending_email' => null,
])->save();
// Die Firmenrechte hängen an der Adresse (Audit 7.1): automatische
// Rechte der alten Adresse sofort zurücknehmen, passende der neuen
// vergeben – nicht erst beim nächsten Login.
$contactAccess->syncMembershipsForEmail($user);
return redirect()->route('me.security')
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
}

View file

@ -3,12 +3,11 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
/**
@ -23,7 +22,7 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect();
}
public function callback(Request $request, SocialAuthService $social): RedirectResponse
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
{
try {
$googleUser = Socialite::driver('google')->user();
@ -46,14 +45,19 @@ class GoogleController extends Controller
]);
}
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
Auth::login($user, remember: true);
$request->session()->regenerate();
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor.
//
// Kein erzwungenes „Angemeldet bleiben" mehr (Audit K-08): wie beim
// Magic Link nur die Sitzung, siehe LoginCompletion.
if ($user->hasEnabledTwoFactorAuthentication()) {
$completion->handOffToTwoFactor($request, $user, 'google');
$user->forceFill([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
])->save();
return redirect()->route('two-factor.challenge');
}
$completion->login($request, $user, 'google');
return redirect(LoginRedirect::safeTarget(
$user,

View file

@ -2,23 +2,52 @@
namespace App\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Auth\LoginCompletion;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MagicLinkConsumeController extends Controller
{
public function __construct(
private readonly ConversionRecorder $recorder,
private readonly CustomerCompanyContext $companies,
private readonly LoginCompletion $completion,
) {}
public function __invoke(Request $request, string $token): RedirectResponse
{
$magicLink = MagicLink::query()
->with('user')
->where('token_hash', hash('sha256', $token))
->where('purpose', 'login')
// Login- und Bildbestätigungslinks melden gleich an (B-10).
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->first();
// Bereits angemeldet (Audit K-08): Die Route liegt nicht mehr hinter
// `guest`. Öffnet ein eingeloggter Kunde seinen EIGENEN Link (etwa die
// Bildbestätigung), landet er auf dem Ziel statt auf seinem Dashboard.
// Jeder andere Link verhält sich wie früher unter `guest`: zurück
// nach Hause, der Link bleibt unberührt.
$current = Auth::guard('web')->user();
if ($current instanceof User) {
if (! $magicLink || $magicLink->user_id !== $current->getKey()) {
return redirect(! $current->hasVerifiedEmail() ? route('verification.notice') : LoginRedirect::homeFor($current));
}
return $this->openOwnLinkWhileLoggedIn($request, $magicLink, $current);
}
if (! $magicLink || ! $magicLink->user) {
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
}
@ -46,6 +75,14 @@ class MagicLinkConsumeController extends Controller
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
}
// Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
// aus der Session — die ist beim Weg über Mail und Domainwechsel
// längst gerissen (Roadmap 0.4).
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
$user = $magicLink->user;
// 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login –
@ -53,36 +90,83 @@ class MagicLinkConsumeController extends Controller
// ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits
// atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen
// neuen an.
//
// Das Landeziel geht als `url.intended` mit durch die Challenge
// (Audit K-08); RoleAwareLoginResponse übernimmt es nur, wenn der
// Nutzer es erreichen darf.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => false,
]);
$request->session()->regenerate();
$this->completion->handOffToTwoFactor($request, $user, 'magic-link', intended: $this->landingFor($user, $magicLink));
return redirect()->route('two-factor.challenge');
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
$this->completion->login($request, $user, 'magic-link');
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();
return redirect($this->landingFor($user, $magicLink));
}
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
/**
* Eigener Link bei bestehender Anmeldung: kein erneuter Login. Ein noch
* gültiger Link wird wie sonst verbraucht (Einmal-Nutzung), ein schon
* verbrauchter oder abgelaufener führt trotzdem zum Ziel — der Nutzer
* ist ja angemeldet.
*/
private function openOwnLinkWhileLoggedIn(Request $request, MagicLink $magicLink, User $user): RedirectResponse
{
if ($magicLink->consumed_at === null && ! $magicLink->expires_at->isPast()) {
$claimed = MagicLink::query()
->whereKey($magicLink->id)
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $request->ip(),
]);
if ($claimed > 0) {
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
}
}
return redirect($this->landingFor($user, $magicLink));
}
/**
* Rollensicheres Landeziel ohne intended(): eine als Gast besuchte
* Admin-URL (z. B. /dashboard) darf einen Customer nach dem
* Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
*/
private function landingFor(User $user, MagicLink $magicLink): string
{
$home = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
return redirect($home);
// „Diesen Newsroom verwalten" (Roadmap 1b.2): direkt auf die Firma,
// um die es ging — sofern der Account sie wirklich verwalten darf.
$landingCompanyId = (int) ($magicLink->payload['company_id'] ?? 0);
if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin()
&& $this->companies->findFor($user, $landingCompanyId) !== null) {
$home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false);
// Aus der Veröffentlichungsmail (Roadmap 1c.2): direkt auf die
// Meldung mit ihren Aufrufen, wenn der Account sie sehen darf.
$landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
if ($landingPressReleaseId > 0) {
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
if ($pressRelease !== null && $user->can('view', $pressRelease)) {
$home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false);
}
}
}
return $home;
}
}

View file

@ -114,7 +114,7 @@ class CheckoutController extends Controller
'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']),
]);
} catch (\Throwable $exception) {
Log::error('Tarifwechsel fehlgeschlagen.', [
Log::channel('billing')->error('Tarifwechsel fehlgeschlagen.', [
'user_id' => $user->id,
'plan' => $plan->slug,
'interval' => $interval,

View file

@ -6,6 +6,8 @@ use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Company\NewsroomClaimService;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -89,6 +91,75 @@ class EmailUnsubscribeController extends Controller
]);
}
/**
* Adressbezogene Abmeldung (Roadmap 1d.3): Empfänger ohne Konto. Die
* Sperrliste hält den Zustand; Anmeldelinks, die die Person selbst
* anfordert, gehen weiter durch (GateOutgoingMail).
*/
public function showAddress(Request $request, EmailSuppressionService $suppressions): View
{
$notificationType = $this->resolveType((string) $request->query('type', ''));
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
if (! $request->hasValidSignature() || $email === null) {
return view('web.email-unsubscribe', ['state' => 'invalid', 'notificationType' => $notificationType]);
}
$this->unsubscribeAddress($suppressions, $email, $notificationType, 'link');
return view('web.email-unsubscribe', [
'state' => 'unsubscribed',
'notificationType' => $notificationType,
'address' => NewsroomClaimService::mask($email),
'addressMode' => true,
'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe-address', ['email' => $email, 'type' => $notificationType->value]),
]);
}
public function oneClickUnsubscribeAddress(Request $request, EmailSuppressionService $suppressions): Response
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$this->unsubscribeAddress($suppressions, $email, $this->resolveType((string) $request->query('type', '')), 'one-click');
return response('Sie wurden erfolgreich abgemeldet.');
}
public function resubscribeAddress(Request $request, EmailSuppressionService $suppressions): View
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$notificationType = $this->resolveType((string) $request->query('type', ''));
if ($suppressions->resubscribe($email)) {
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat die Abmeldung zurückgenommen.', $email),
meta: ['email' => $email, 'action' => 'resubscribe'],
);
}
return view('web.email-unsubscribe', ['state' => 'resubscribed', 'notificationType' => $notificationType, 'addressMode' => true]);
}
private function unsubscribeAddress(EmailSuppressionService $suppressions, string $email, EmailNotificationType $notificationType, string $method): void
{
if ($suppressions->isSuppressed($email)) {
return;
}
$suppressions->unsubscribe($email, $method);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat sich von Benachrichtigungen abgemeldet (%s).', $email, $notificationType->unsubscribeLabel()),
meta: ['email' => $email, 'type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method],
);
}
private function optOut(User $user, EmailNotificationType $notificationType, string $method): void
{
if (! $user->wantsEmailNotification($notificationType)) {

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tiefer Health-Check für den externen Monitor (Monitoring-Plan 1.5).
*
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
*/
class HealthController extends Controller
{
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
{
$token = (string) config('monitoring.health.token');
$given = (string) ($request->bearerToken() ?? $request->query('token', ''));
abort_if($token === '' || ! hash_equals($token, $given), 404);
$checks = $probe->checks();
$status = HealthProbe::worstStatus($checks);
return response()->json([
'status' => $status,
'version' => config('app.version'),
'checked_at' => now()->toIso8601String(),
'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [
$check['key'] => ['status' => $check['status'], 'detail' => $check['detail']],
]),
], $status === HealthProbe::StatusCritical ? 503 : 200)
->header('Cache-Control', 'no-store');
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use App\Services\Billing\ManualInvoicePdfRenderer;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF einer MAN-Rechnung (on-demand gerendert). STR-Rechnungen haben ihren
* Beleg bei Stripe (`StripeInvoiceRedirectController`) — hier 404.
*/
class InvoicePdfController extends Controller
{
public function __invoke(Invoice $invoice, ManualInvoicePdfRenderer $renderer): Response
{
abort_unless($invoice->isManual(), 404);
Gate::authorize('downloadPdf', $invoice);
return $renderer->inlineResponse($invoice);
}
}

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