presseportale/dev/betrieb/deploy-runbook.md
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

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

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

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

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

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

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

Block 4 — KI-Prüfung und Scheduler

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

328 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Deploy-Runbook — Pressekonto (Laravel-System)
**Stand:** 09.07.2026 · **Gilt für:** das neue Laravel-System (Hub pressekonto + Spokes presseecho/businessportal24).
`FTP-LIVE-DEPLOY.md` betrifft nur das Alt-Symfony-System und ist für dieses System **nicht** anwendbar.
> Referenz-Plan: `dev/archive/release/entwicklungsplan-release.md` (R5.2). Externe Voraussetzungen
> (Stripe-Live-Keys, echte Mail-/Kontaktadressen, Favicons, finale Rechtstexte) siehe dort.
---
## 1. Server-Voraussetzungen
- PHP 8.4 (FPM) mit Extensions: `pdo_mysql`, `mbstring`, `intl`, `gd` (Bildvarianten), `curl`, `zip`
- MySQL/MariaDB (Prod: `DB_CONNECTION=mysql`; SQLite ist nur Dev)
- Node.js (LTS) + npm — nur zum Bauen der Assets (Builds sind **gitignored**, siehe Schritt 3)
- Webserver-vHosts für **alle drei Domains** auf dasselbe `public/` (Host-Routing macht die App)
- HTTPS-Zertifikate für alle drei Domains
- Prozess-Manager (systemd/Supervisor) für den Queue-Worker
**Wichtig (SEO):** Keine statische `public/robots.txt` anlegen — robots.txt und sitemap.xml
sind dynamische, host-aware Laravel-Routen; eine echte Datei würde sie verschatten.
## 2. `.env` (Produktion) — Solltabelle
Basis: `.env.example` kopieren und anpassen. Abweichend von den Dev-Defaults zwingend:
| Key | Prod-Wert |
|---|---|
| `APP_ENV` | `production` |
| `APP_DEBUG` | `false` |
| `APP_KEY` | `php artisan key:generate` (einmalig) |
| `APP_URL` / `APP_PORTAL_*` / `APP_PRESSEECHO_*` / `APP_BUSINESSPORTAL_*` | echte Domains (pressekonto.de, presseecho.de, businessportal24.com) |
| `SANCTUM_STATEFUL_DOMAINS` | echte Domains, kommasepariert |
| `DB_CONNECTION` + `DB_*` | MySQL-Zugangsdaten |
| `MAIL_MAILER` + `MAIL_*` | echter Mailer — **Default `log` versendet nichts!** |
| `MAIL_FROM_ADDRESS` | echte Absenderadresse |
| `LOG_STACK` | `daily` (der Default `single` wächst unbegrenzt; `daily` rotiert mit `LOG_DAILY_DAYS`, Default 14) |
| `MONITORING_ALERT_EMAIL` | Ops-Postfach für Betriebs-Alerts (R5.3); leer = Fallback auf `LEGAL_CONTACT_EMAIL` |
| `SESSION_SECURE_COOKIE` | `true` |
| `BASIC_AUTH_ENABLED` | Staging: `true` + User/Passwort · Live: `false` |
| `BILLING_ENFORCE_BOOKING` | `true` (Default) |
| `STRIPE_KEY/SECRET/WEBHOOK_SECRET` + `STRIPE_PRICE_SINGLE_PM` | **Live**-Werte [EXTERN R1.2] |
| `BILLING_OWN_VAT_ID` | echte USt-IdNr. |
| `OPENAI_API_KEY` | Prod-Key (ohne ihn keine Klassifikation/kein Score) |
| `CONTACT_EMAIL_*` | echte Empfängeradressen [EXTERN R2.1] |
| `GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI` | Live-OAuth-Client (Redirect-URI exakt) |
## 3. Build & Release-Schritte
```bash
# 1) Code auslegen (git pull / Release-Verzeichnis)
composer install --no-dev --optimize-autoloader
# 2) Assets bauen (Builds sind gitignored — auf dem Server oder in CI bauen
# und public/build/ mit ausliefern)
npm ci
npm run build # baut portal + web (public/build/portal, public/build/web)
# 3) Storage-Symlink (Firmenlogos, PM-Bilder)
php artisan storage:link
# 4) Datenbank
php artisan migrate --force
# 5) Seeder — NUR die Stammdaten-Seeder. DatabaseSeeder ist prod-sicher
# (Demo-User nur in local/testing), aber explizit ist besser:
php artisan db:seed --class=RolesAndPermissionsSeeder --force
php artisan db:seed --class=AdminPresetSeeder --force
php artisan db:seed --class=PlanSeeder --force
php artisan db:seed --class=PaymentOptionSeeder --force
php artisan db:seed --class=LegalDocumentSeeder --force
php artisan db:seed --class=RejectionCategorySeeder --force
# 5a) Nach frischem Seeding (Erst-Einrichtung oder DB-Reset): Stripe-Produkte/
# Preise neu verknüpfen — die Price-IDs stehen in der plans-Tabelle:
# php artisan billing:sync-stripe-plans
# 6) Caches bauen (bei JEDEM Deploy neu)
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# 7) Queue-Worker neu starten (lädt neuen Code)
php artisan queue:restart
```
### 3a. Achtung: Migrationen auf `press_releases` (FULLTEXT erzwingt Tabellen-Kopie)
`press_releases` trägt zwei FULLTEXT-Indizes (`press_releases_admin_search_fulltext`
auf title+keywords, `press_releases_web_search_fulltext` auf title+subtitle+text+keywords).
Deshalb lehnt MySQL für **jede Spaltenänderung** ALGORITHM=INSTANT **und** INPLACE ab
("InnoDB presently supports one FULLTEXT index creation at a time") und fällt auf
ALGORITHM=COPY zurück — bei importiertem Bestand (~175k Zeilen, schwere Textspalten)
lief das auf Dev mit ~30 Zeilen/s, also **1,5 h+ pro ALTER** (beobachtet 08./09.07.2026
bei der KI-Kennzeichnungs-Migration). Nach dem Legacy-Import gilt das auf Prod genauso.
Bewährter Ablauf für solche Migrationen (Dev-erprobt, gesamt ~6 Min statt Stunden):
```sql
-- 1) FULLTEXT-Indizes droppen (Suche ist solange offline!)
ALTER TABLE press_releases DROP INDEX press_releases_admin_search_fulltext;
ALTER TABLE press_releases DROP INDEX press_releases_web_search_fulltext;
-- 2) Migration laufen lassen: php artisan migrate --force
-- (Spalten-ALTER geht jetzt per INPLACE in ~40 s; INSTANT bleibt wegen
-- der versteckten FTS_DOC_ID-Spalte auch ohne Indizes gesperrt)
-- 3) Indizes NACHEINANDER neu aufbauen (InnoDB baut nur einen FULLTEXT gleichzeitig;
-- Dev-Zeiten: admin ~40 s, web ~4,5 Min)
ALTER TABLE press_releases ADD FULLTEXT press_releases_admin_search_fulltext (title, keywords);
ALTER TABLE press_releases ADD FULLTEXT press_releases_web_search_fulltext (title, subtitle, text, keywords);
```
Dazu beachten:
- **Wartungsfenster einplanen**: zwischen Schritt 1 und 3 liefern Admin- und Web-Suche
Fehler/keine Treffer. Alternativ die volle COPY-Dauer akzeptieren (Suche bleibt online,
Schreibzugriffe auf PMs sind währenddessen aber gesperrt).
- **DB-Backup vorher** (gilt ohnehin, siehe Abschnitt 9).
- Solche Migrationen **wiederaufnehmbar** schreiben (`Schema::hasColumn`-Guards, kein
`->after()`), Vorlage: `2026_07_08_172023_add_ai_declaration_to_press_releases.php`.
Hintergrund: Laravel legt pro Spalte ein eigenes ALTER ab — bricht der Prozess
mittendrin ab, ist die Migration halb angewandt und ohne Guards nicht wiederholbar.
- Einen laufenden COPY-ALTER **nicht clientseitig killen**: der Server-Thread läuft als
Zombie weiter und Laravel reiht das Statement nach `KILL <id>` per Lost-Connection-Retry
erneut ein — es stauen sich doppelte ALTERs am Metadata-Lock. Falls doch nötig:
in MySQL `SHOW PROCESSLIST` prüfen und **alle** betroffenen Threads killen.
- Beim allerersten Go-Live ist das unkritisch: `php artisan migrate` läuft dort **vor**
dem Legacy-Import auf leeren Tabellen (Reihenfolge aus Abschnitt 7 einhalten).
## 4. Queue-Worker (Pflicht)
Die KI-Jobs (`ClassifyPressRelease`, `ScorePressRelease`) laufen auf der
database-Queue **`classification`** — ohne Worker bleibt jede eingereichte PM
in der Prüfung hängen.
systemd-Beispiel (`/etc/systemd/system/pressekonto-worker.service`):
```ini
[Unit]
Description=Pressekonto Queue Worker
After=network.target
[Service]
User=www-data
Restart=always
ExecStart=/usr/bin/php /var/www/pressekonto/artisan queue:work database \
--queue=classification,default --tries=3 --max-time=3600
[Install]
WantedBy=multi-user.target
```
Zeitbudgets (Audit F17, 18.09.2026): Die KI-Jobs (`ClassifyPressRelease`,
`ScorePressRelease`) tragen `$timeout = 120`, weil drei HTTP-Versuche à 30 s
die 60 s des Worker-Defaults sprengen. `retry_after` der Datenbank-Queue
steht deshalb auf 150 (`config/queue.php`, überschreibbar per
`DB_QUEUE_RETRY_AFTER`). Wer einen zweiten Worker startet, hält
`retry_after` über dem größten Job-Timeout — sonst greift ein Worker den
noch laufenden Job des anderen.
## 5. Scheduler / Cron (Pflicht)
Crontab des Web-Users:
```cron
* * * * * cd /var/www/pressekonto && php artisan schedule:run >> /dev/null 2>&1
```
Aktive Tasks (`php artisan schedule:list`):
| Zeitplan | Task | Ohne Cron passiert … |
|---|---|---|
| alle 5 Min | `press-releases:publish-scheduled` | **geplante PMs werden nie veröffentlicht** |
| täglich 03:00 | `magic-links:purge --days=30` | Token-Tabelle wächst |
| täglich 04:30 | `billing:generate-manual-invoices` | manuelle Rechnungen fehlen |
| So 04:00 | `press-releases:purge-drafts --days=180` | Alt-Entwürfe bleiben liegen |
| monatl. 1. 00:05 | `press-releases:reset-monthly-quota` | **Kontingente werden nie zurückgesetzt** |
| alle 15 Min | `market:refresh` | Börsendaten im Ticker veralten |
**Monitoring (R5.3):** Jeder Scheduler-Task hat ein `->onFailure()`, der
Queue-Worker einen `Queue::failing()`-Listener. Fehlschläge werden als
`critical` ins Tageslog geschrieben **und** an `MONITORING_ALERT_EMAIL`
gemailt (Fallback `LEGAL_CONTACT_EMAIL`; abschaltbar über
`MONITORING_ALERTS_ENABLED=false`). Die Mail ist Best-Effort — der Log-Eintrag
ist die verlässliche Spur, daher `LOG_STACK=daily` setzen.
## 6. Stripe-Webhook
Endpoint in Stripe (Live-Mode) registrieren: `https://pressekonto.de/stripe/webhook`
→ Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die `.env`.
Events: die Cashier-Defaults (Subscriptions, Invoices, Payment-Intents).
## 7. Erst-Einrichtung (nur beim allerersten Go-Live)
# kopieren der Bilddaten per rync #@jk:8#PN§!4V02wR:rV#
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/thumbnails/ \
dev/migration/presseecho/thumbnails/
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/uploads/ \
dev/migration/presseecho/uploads/
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/thumbnails/ \
dev/migration/businessportal24/thumbnails/
rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/uploads/ \
dev/migration/businessportal24/uploads/
1. Legacy-Datenimport (Reihenfolge beachten, read-only-Quellen konfiguriert):
`legacy:import` → `legacy:fix-timestamps` → `legacy:migrate-media`
(bzw. `legacy:sync-company-logos` / `legacy:sync-press-release-images`)
→ `legacy:archive-invoices` → `legacy:grandfather-subscriptions`.
2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1,
Reihenfolge beachten): `legacy:migrate-agency-names` →
`companies:retire-agency-type`. Beide erst mit `--dry-run` (Reports unter
`storage/app/private/migration/` prüfen, insbes. Mehrfach-Namen,
Owner-lose Agentur-Firmen und geschützte Fälle), dann scharf.
Namen werden nie überschrieben; gelöscht wird nur soft und nur ohne
PMs/Kontakte/Dritt-Mitgliedschaften/Zahlungsoptions-Verknüpfung.
3. Ersten Admin-User anlegen (Prod hat keine Demo-User): normal registrieren
(inkl. E-Mail-Verifizierung), dann Rolle via Tinker setzen —
`syncRoles` ersetzt dabei alle bisherigen Rollen des Users:
```bash
php artisan tinker --execute 'App\Models\User::where("email", "mail@example.com")->firstOrFail()->syncRoles(["admin"]);'
```
4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4].
5. Smoke-Test: siehe Abschnitt 8.
1. Medien migrieren
# Erst prüfen, ob die Ordnerstruktur stimmt (zählt "Datei fehlt"/"Ziel fehlt")
php artisan legacy:migrate-media --portal=all --dry-run
# Dann scharf (kopiert + erzeugt Bildvarianten — dauert bei der Menge eine Weile)
php artisan legacy:migrate-media --portal=all
Kleine „Datei fehlt"-Restmengen sind bei Altbeständen normal (verwaiste DB-Einträge); große Zahlen heißen Ordnerproblem — dann melde dich mit der Ausgabe.
2. PM-Medien privatisieren (Pflicht, direkt danach)
php artisan press-releases:privatize-media
Verschiebt die PM-Bilder von der public- auf die private-Disk (Security-Hardening). Muss nach migrate-media laufen — und nach jedem späteren Medien-Lauf erneut.
3. Timestamps korrigieren (bewusst NACH den Medien)
php artisan legacy:fix-timestamps
Setzt created_at/updated_at/published_at authoritativ aus den Legacy-DBs. Nach den Medien deshalb, weil die Medien-Migration die Datensätze anfasst und updated_at neu stempelt — der Fix räumt das wieder auf. Wichtig fürs Frontend: Die Sortierung hängt an published_at.
4. Satelliten-Migrationen (jeweils erst --dry-run, dann scharf)
php artisan legacy:import-api-keys --dry-run # lokal waren es 406 Keys — Zahl vergleichen
php artisan legacy:archive-invoices --dry-run
php artisan legacy:grandfather-subscriptions --dry-run # MUSS nach archive-invoices (leitet sich aus dem Rechnungsarchiv ab)
php artisan legacy:import-billing-addresses --dry-run # Report: Kunden mit leeren/kaputten Alt-Adressen → manuell nachpflegen
php artisan legacy:migrate-agency-names --dry-run
php artisan companies:retire-agency-type --dry-run # NACH migrate-agency-names
Die Reports landen unter storage/app/private/migration/ — besonders bei den letzten beiden reinschauen (Mehrfach-Namen, Owner-lose Agentur-Firmen), bevor du sie scharf laufen lässt.
5. Das Gate: Gesamtverifikation
php artisan legacy:verify
JSON-Report prüfen → das ist deine Go/No-Go-Entscheidung für den Cutover. Wenn du magst, reich mir den Report hier rein, ich gehe ihn mit dir durch.
Danach: Stichproben im Browser
Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo, Archiv — und ein Blick in Admin ▸ Reports ▸ Betriebsüberwachung (alle Kacheln grün, besonders „Medien-Privatisierung").
## 8. Smoke-Test nach jedem Deploy
- [ ] Alle drei Domains laden (Hub-Landing, 2× Spoke-Home) über HTTPS
- [ ] `/robots.txt` + `/sitemap.xml` je Domain liefern hostkorrekte Inhalte
- [ ] Login am Portal funktioniert; `/admin` erreichbar
- [ ] Eine Test-PM einreichen → Klassifikation läuft durch (Worker-Check)
- [ ] Kontaktformular + Widerrufs-/Kündigungsformular versenden Mails (echter Mailer!)
- [ ] Stripe-Checkout im Live-Mode mit kleiner Testbuchung + Webhook-Eingang prüfen
- [ ] `storage/logs/laravel.log` auf Fehler prüfen
## 9. Rollback
- Code: vorheriges Release auschecken → Schritte 6–7 aus Abschnitt 3 wiederholen
(`config:cache` … `queue:restart`).
- Migrationen sind additiv gehalten; kein automatisches `migrate:rollback` in Prod —
im Fehlerfall DB-Backup einspielen (Backup **vor** jedem Deploy mit Migrationen!).
## 10. Betrieb nach Go-Live (Stand 20.07.2026 — System ist LIVE)
Live-Setup: Hetzner-Cloud-Server via **Ploi**, App unter `~/pressekonto.com`,
Deploys laufen per **SFTP-Datei-Upload** (kein Git-Deploy). Echte Domains
(pressekonto.com, presseecho.de, businessportal24.com) inkl. www-DNS geschaltet,
Stripe im Live-Mode, `legacy:verify` 0 failures. Migration/Cutover: erledigt,
Doku archiviert unter `dev/archive/optimize/cutover-runbook.md`.
### SFTP-Deploy-Checkliste (jedes Mal)
1. Geänderte Dateien hochladen.
2. `php artisan view:clear && php artisan view:cache` — **immer bei
Blade-/Volt-Änderungen** (auch Livewire-Komponenten sind Blade-Dateien).
3. `php artisan config:cache` — nur bei `.env`- oder `config/`-Änderungen.
4. `php artisan migrate --force` — nur wenn neue Migrationen dabei sind
(vorher DB-Backup).
5. `php artisan queue:restart` — **immer, wenn PHP-Dateien dabei waren.**
Der Worker ist ein Daemon und hält alten Code im Speicher; ohne Restart
laufen Jobs (KI-Prüfung, Mails) mit dem alten Stand.
### Betriebs-Erkenntnisse aus der Go-Live-Woche
- **Worker-Queues:** Der Worker MUSS `--queue=classification,default` fahren
(Abschnitt 4) — sonst bleiben die KI-Jobs liegen und eingereichte PMs
hängen unbemerkt in der Prüfung.
- **Nach MySQL-Neustarts** hängen Worker mitunter in kaputten Verbindungen →
Worker in Ploi neu starten, danach ggf. `php artisan queue:retry all`.
- **Öffentliche Seitendaten** laufen über Stale-while-revalidate-Caching
(`App\Support\WebPageCache`, `Cache::flexible`) — nach `cache:clear` ist der
jeweils erste Aufruf pro Seite langsam (Kaltaufbau), danach dauerhaft schnell.
- **www-Hosts** werden zweifach behandelt: `RedirectWwwToApex`-Middleware
(301 auf die Apex-Domain) plus Host-Normalisierung in `DomainAssetContext`
als Sicherheitsnetz. Ploi kann keine Host-Redirects (nur Pfad-Redirects).
- **Betriebsüberwachung:** Admin ▸ Reports ▸ Betriebsüberwachung (Queue-Worker,
Medien-Privatisierung u. a.) regelmäßig prüfen; Fehlerlog unter
`storage/logs/laravel.log`.
- Offene Punkte und nächste Entwicklungen: `dev/roadmap.md`.