Compare commits
119 commits
e6eca9c85f
...
b1cec167d6
| Author | SHA1 | Date | |
|---|---|---|---|
| b1cec167d6 | |||
| 8a64bd7147 | |||
| 33dc9918cc | |||
| df7ebdca10 | |||
| 22b62dfc1f | |||
| 8769a71100 | |||
| f2ed268b51 | |||
| 2701588ac1 | |||
| aa47e113fe | |||
| 5ec252da12 | |||
| 7cd84b2be3 | |||
| f0eeef0144 | |||
| c2e0b85242 | |||
| 1d417f87f7 | |||
| dfb0513e73 | |||
| 98c465b691 | |||
| 21e88710b6 | |||
| 97cd8f9e90 | |||
| b80cce6c37 | |||
| 48129e99a0 | |||
| 4e34c3d0d1 | |||
| d99f55e555 | |||
| 6d558cd26a | |||
| 0e5cd20626 | |||
| 3b613fadf4 | |||
| 62723b551a | |||
| 0240c143c8 | |||
| 46b89e1d23 | |||
| 2124ffd1f2 | |||
| 65ac72ce05 | |||
| 40953d6c65 | |||
| e9bdff3ad3 | |||
| b624f80d7f | |||
| 605d853ff2 | |||
| 5ef123c3c7 | |||
| f9019eecda | |||
| dab14dc810 | |||
| e620ef301f | |||
| 44d0bb6f29 | |||
| 23811ad4d2 | |||
| d0c7194491 | |||
| b423650d80 | |||
| 2ec61697b0 | |||
| b8c6af5f10 | |||
| 3ea9688c9a | |||
| 7819527c12 | |||
| ea68362310 | |||
| 1cd9fd11af | |||
| 235d3928ab | |||
| fb03fb166d | |||
| c882194330 | |||
| ec6705ee99 | |||
| 4fa9859024 | |||
| 375f4dffce | |||
| f235156de9 | |||
| 62202a4b13 | |||
| a7f991f01e | |||
| 0ab53bfa5e | |||
| 24c1bbf500 | |||
| 4c17a17254 | |||
| ee267c622e | |||
| 447dc4c61e | |||
| 10c91ac3f5 | |||
| b44fdedbdf | |||
| f1648a05b0 | |||
| 6c4618afed | |||
| 7088d092d8 | |||
| 6446e19727 | |||
| 1fd6d2c0de | |||
| b6e4bc5dc0 | |||
| 84ea617107 | |||
| b74f881ac8 | |||
| 713024be37 | |||
| 99ffd012d4 | |||
| 87ca9082a9 | |||
| 4fd1db7825 | |||
| 35db30e019 | |||
| 0692d892e0 | |||
| c08c9e5de0 | |||
| 58bc82a91f | |||
| 09ec866aac | |||
| 36691a5b13 | |||
| ada41cb067 | |||
| fddfd8a61c | |||
| 71d9639491 | |||
| e84c2772ed | |||
| e80006589c | |||
| 0a658bb34b | |||
| 87f95fb7a7 | |||
| 079477a423 | |||
| 01014887cf | |||
| 9143cc4a54 | |||
| abfcd774e0 | |||
| 0be476b821 | |||
| 8baca9dd78 | |||
| d60f31d78e | |||
| cd16fd14f3 | |||
| 202cf340f9 | |||
| 1368e2cf07 | |||
| 8b86b88614 | |||
| 982bd6eff4 | |||
| 00af8b1d8a | |||
| e2f7cf1624 | |||
| 5e5414ad20 | |||
| 5e46ff9f68 | |||
| 5d0570bee0 | |||
| e536e52cdb | |||
| 2691a4d988 | |||
| 2391c853e3 | |||
| aab22401d0 | |||
| 81d9c43ab7 | |||
| 8d416693c0 | |||
| e746deda4f | |||
| eb1eba4533 | |||
| 49182ec9c6 | |||
| 48d401e68f | |||
| 2024df9d28 | |||
| d14cb84107 | |||
| 8fc18c239b |
749 changed files with 71435 additions and 12543 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
131
.env.example
131
.env.example
|
|
@ -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=
|
||||
|
|
|
|||
17
.github/workflows/lint.yml
vendored
17
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/tests.yml
vendored
7
.github/workflows/tests.yml
vendored
|
|
@ -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
16
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
CLAUDE.md
47
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
208
app/Console/Commands/AggregatePressReleaseViews.php
Normal file
208
app/Console/Commands/AggregatePressReleaseViews.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/ArchiveExportTombstones.php
Normal file
34
app/Console/Commands/ArchiveExportTombstones.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Console/Commands/ArchiveKey.php
Normal file
24
app/Console/Commands/ArchiveKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/ArchivePurge.php
Normal file
40
app/Console/Commands/ArchivePurge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
app/Console/Commands/ArchiveReconcileAfterRestore.php
Normal file
43
app/Console/Commands/ArchiveReconcileAfterRestore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
app/Console/Commands/ArchiveReregister.php
Normal file
62
app/Console/Commands/ArchiveReregister.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/ArchiveRetryPending.php
Normal file
61
app/Console/Commands/ArchiveRetryPending.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
app/Console/Commands/ArchiveSweepOrphans.php
Normal file
62
app/Console/Commands/ArchiveSweepOrphans.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Console/Commands/ArchiveVerifyExport.php
Normal file
54
app/Console/Commands/ArchiveVerifyExport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
app/Console/Commands/AssignCanonicalPressReleases.php
Normal file
67
app/Console/Commands/AssignCanonicalPressReleases.php
Normal 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;
|
||||
}
|
||||
}
|
||||
176
app/Console/Commands/AuditCompanyMemberships.php
Normal file
176
app/Console/Commands/AuditCompanyMemberships.php
Normal 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;
|
||||
}
|
||||
}
|
||||
84
app/Console/Commands/BackfillImageRightsConfirmations.php
Normal file
84
app/Console/Commands/BackfillImageRightsConfirmations.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
app/Console/Commands/BackfillPublishedEver.php
Normal file
63
app/Console/Commands/BackfillPublishedEver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/Console/Commands/CheckIndexStatus.php
Normal file
91
app/Console/Commands/CheckIndexStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
114
app/Console/Commands/CheckMailDomains.php
Normal file
114
app/Console/Commands/CheckMailDomains.php
Normal 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();
|
||||
}
|
||||
}
|
||||
117
app/Console/Commands/ClassifyCompanyAddresses.php
Normal file
117
app/Console/Commands/ClassifyCompanyAddresses.php
Normal 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);
|
||||
}
|
||||
}
|
||||
282
app/Console/Commands/ExpireGrandfathering.php
Normal file
282
app/Console/Commands/ExpireGrandfathering.php
Normal 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');
|
||||
}
|
||||
}
|
||||
100
app/Console/Commands/FetchMailBounces.php
Normal file
100
app/Console/Commands/FetchMailBounces.php
Normal 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);
|
||||
}
|
||||
}
|
||||
208
app/Console/Commands/FixApiPortalDuplicates.php
Normal file
208
app/Console/Commands/FixApiPortalDuplicates.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] : []),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
70
app/Console/Commands/HashPressReleaseContent.php
Normal file
70
app/Console/Commands/HashPressReleaseContent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
229
app/Console/Commands/ImportNewsletterBounces.php
Normal file
229
app/Console/Commands/ImportNewsletterBounces.php
Normal 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],
|
||||
)));
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/ListCompanyMerges.php
Normal file
115
app/Console/Commands/ListCompanyMerges.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/MailCircuitCommand.php
Normal file
36
app/Console/Commands/MailCircuitCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
180
app/Console/Commands/MergeCompanies.php
Normal file
180
app/Console/Commands/MergeCompanies.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
25
app/Console/Commands/MonitoringHeartbeat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
219
app/Console/Commands/OnlineSchemaChange.php
Normal file
219
app/Console/Commands/OnlineSchemaChange.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
app/Console/Commands/PurgeConversionEvents.php
Normal file
107
app/Console/Commands/PurgeConversionEvents.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
170
app/Console/Commands/PurgePressReleaseEvents.php
Normal file
170
app/Console/Commands/PurgePressReleaseEvents.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
101
app/Console/Commands/RegeneratePressReleaseImageVariants.php
Normal file
101
app/Console/Commands/RegeneratePressReleaseImageVariants.php
Normal 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;
|
||||
}
|
||||
}
|
||||
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal file
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
app/Console/Commands/ReportCompanyMerge.php
Normal file
105
app/Console/Commands/ReportCompanyMerge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
80
app/Console/Commands/ReportConversionFunnel.php
Normal file
80
app/Console/Commands/ReportConversionFunnel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
72
app/Console/Commands/ReportDuplicatePressReleases.php
Normal file
72
app/Console/Commands/ReportDuplicatePressReleases.php
Normal 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;
|
||||
}
|
||||
}
|
||||
125
app/Console/Commands/ReportPressReleaseViews.php
Normal file
125
app/Console/Commands/ReportPressReleaseViews.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
app/Console/Commands/ReportRejectionRate.php
Normal file
90
app/Console/Commands/ReportRejectionRate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
100
app/Console/Commands/ResumeNewsletterCampaign.php
Normal file
100
app/Console/Commands/ResumeNewsletterCampaign.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
106
app/Console/Commands/RetireLinkSpamCompanies.php
Normal file
106
app/Console/Commands/RetireLinkSpamCompanies.php
Normal 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');
|
||||
}
|
||||
}
|
||||
224
app/Console/Commands/ReviewApiCompanyAssignments.php
Normal file
224
app/Console/Commands/ReviewApiCompanyAssignments.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
83
app/Console/Commands/SeedCategoryDescriptions.php
Normal file
83
app/Console/Commands/SeedCategoryDescriptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
358
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
358
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal 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, ',', '.');
|
||||
}
|
||||
}
|
||||
101
app/Console/Commands/StripLinksFromCompanyNames.php
Normal file
101
app/Console/Commands/StripLinksFromCompanyNames.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
app/Console/Commands/SweepOrphanPressReleaseOriginals.php
Normal file
55
app/Console/Commands/SweepOrphanPressReleaseOriginals.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
app/Console/Commands/UnmergeCompanies.php
Normal file
67
app/Console/Commands/UnmergeCompanies.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Enums/AiDeclarationSource.php
Normal file
27
app/Enums/AiDeclarationSource.php
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
26
app/Enums/ArchiveEvent.php
Normal file
26
app/Enums/ArchiveEvent.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/Enums/ArchiveReasonCategory.php
Normal file
29
app/Enums/ArchiveReasonCategory.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
57
app/Enums/CompanyAddressClass.php
Normal file
57
app/Enums/CompanyAddressClass.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Enums/CompanyMergeSubject.php
Normal file
35
app/Enums/CompanyMergeSubject.php
Normal 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';
|
||||
}
|
||||
15
app/Enums/ConfirmationChannel.php
Normal file
15
app/Enums/ConfirmationChannel.php
Normal 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';
|
||||
}
|
||||
81
app/Enums/ConversionEventType.php
Normal file
81
app/Enums/ConversionEventType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Enums/ConversionSource.php
Normal file
54
app/Enums/ConversionSource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
14
app/Enums/CreditSource.php
Normal file
14
app/Enums/CreditSource.php
Normal 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';
|
||||
}
|
||||
38
app/Enums/EmailBounceKind.php
Normal file
38
app/Enums/EmailBounceKind.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
71
app/Enums/EmailDomainState.php
Normal file
71
app/Enums/EmailDomainState.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
29
app/Enums/EmailSuppressionReason.php
Normal file
29
app/Enums/EmailSuppressionReason.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/Enums/ImagePrecheckCategory.php
Normal file
78
app/Enums/ImagePrecheckCategory.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
38
app/Enums/ImagePrecheckOutcome.php
Normal file
38
app/Enums/ImagePrecheckOutcome.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
51
app/Enums/ImageProvenanceStatus.php
Normal file
51
app/Enums/ImageProvenanceStatus.php
Normal 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
62
app/Enums/IndexState.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
app/Exceptions/MailDeliveryPausedException.php
Normal file
12
app/Exceptions/MailDeliveryPausedException.php
Normal 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 {}
|
||||
12
app/Exceptions/ManualInvoiceActionException.php
Normal file
12
app/Exceptions/ManualInvoiceActionException.php
Normal 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 {}
|
||||
60
app/Http/Controllers/Admin/ArchiveEntryController.php
Normal file
60
app/Http/Controllers/Admin/ArchiveEntryController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'))]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
67
app/Http/Controllers/Api/V1/LegalAcceptanceController.php
Normal file
67
app/Http/Controllers/Api/V1/LegalAcceptanceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
39
app/Http/Controllers/HealthController.php
Normal file
39
app/Http/Controllers/HealthController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/InvoicePdfController.php
Normal file
24
app/Http/Controllers/InvoicePdfController.php
Normal 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
Loading…
Add table
Add a link
Reference in a new issue