- WebPortalContext::fromRequest() buendelt die zuvor ~6x je Route-Closure
wiederholte Host->Portal-Ableitung (portal, language, portalValues,
domainConfig).
- DomainAssetContext::applyWebDomainConfig() ist die Single Source of Truth fuer
die Domain-/Theme-Anwendung (config + View::share + Vite); die fruehere
$applyWebDomainConfig-Closure in routes/web.php ist nur noch ein duenner
Delegations-Wrapper, damit Bare-Root- und Edition-Home-Route unveraendert
per Closure aufrufen koennen.
- Die 6 Portal-Seiten-Closures (Kategorie, Detail, Veroeffentlichen, Suche,
Newsrooms, Newsroom-Detail) sowie Preise nutzen jetzt den zentralen Kontext.
Abgesichert durch die 92 Web/-Tests (jede Seite laeuft ueber den neuen Kontext).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vier komponierbare Scopes auf PressRelease:
- published() Status freigegeben + published_at gesetzt (ohne Zeitschranke,
damit Statistik-Fenster frei einschraenken koennen)
- visibleInArchive() published() + published_at <= jetzt (oeffentlich sichtbar)
- forPortalValues() whereIn portal
- forLanguage() where language
~19 duplizierte Query-Bloecke in routes/web.php (Home, Kategorie, Detail,
Veroeffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) nutzen
jetzt diese Scopes. Jedes withoutGlobalScope(PortalScope) bleibt an seiner
Stelle erhalten; die Klausel-Reihenfolge ist fuers Ergebnis irrelevant (reine
AND-Verknuepfung), daher verhaltensgleich.
Test: Web/PublishedReleaseScopeTest; die 89 bestehenden Web/-Tests bleiben gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Gate::define('access-admin'|'access-customer') als Single Source of Truth im
AppServiceProvider (auch via @can/Policies nutzbar). EnsureUserIsAdmin und
EnsureUserIsCustomer pruefen jetzt ueber diese Gates.
- Impersonation-Redirect aus EnsureUserIsAdmin in die eigene Middleware
BlockAdminDuringImpersonation ausgelagert (Sitzungs-Bedingung statt
Rollenfrage); laeuft vor der Zugriffspruefung und ist zusaetzlich als
persistente Livewire-Middleware registriert.
- Routen-Alias 'can:' bewusst NICHT verwendet: die Admin-Middleware ist
persistente Livewire-Middleware und braucht eine konkrete Klasse; der
parametrisierte Alias kann dort nicht stehen. EnsureApiUserIsActive bleibt
eigenstaendig (is_active-Pruefung mit getesteter JSON-Antwort, kein Gate).
- Tests: Auth/AccessGatesTest; UserImpersonationTest bleibt gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- VatResolver::normalizeCountryCode()/isEuCountry() als Single Source of Truth;
resolve(), VatIdValidationService und StripeCheckoutService nutzen jetzt die
zentrale EU-Pruefung statt eigener config-Lookups.
- User::findByEmail() buendelt die LOWER(email)-Lookups (ContactAccessService,
SocialAuthService). Bleibt bewusst case-insensitiv, da E-Mails nicht
normalisiert gespeichert werden; der indexfreundliche Direktvergleich
braucht erst eine Schreib-Normalisierung (offen, dokumentiert).
- config('billing.default_currency') ersetzt 8 verstreute 'EUR'/'eur'-Literale
(Stripe-Calls via strtolower). Vereinheitlicht nebenbei die zuvor uneinheitlich
gespeicherte SinglePurchase.currency.
- Neuer CustomerAccountProvisioner::provisionVerifiedCustomer() ist die
gemeinsame Provisioning-Methode (verifiziert + aktiv + customer-Rolle) fuer
ContactAccessService und SocialAuthService.
- Tests: UserFindByEmailTest, erweiterte VatResolverTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- LogApiUsage als terminable Middleware: der ApiUsageLog-INSERT laeuft nach
gesendeter Antwort und verlaengert nicht mehr die Request-Dauer. Startzeit
wird ueber ein Request-Attribut uebergeben (terminate() laeuft auf frischer
Instanz).
- User::openSinglePurchaseCount() memoisiert die offenen Einmalkaeufe pro
Request. Vorher wurde derselbe grantingSubmission-Count im Status-Widget
mehrfach (Quota-Total, Quota-Remaining, openSinglePurchases) plus ein
exists() in hasActiveBooking abgesetzt -> jetzt eine Query.
- CustomerCompanyContext::selectedCompany() nutzt eine einzige first()-Query
statt exists()+first(); das Verwerfen nicht (mehr) erreichbarer Auswahlen
bleibt erhalten.
- LegacySubscriptionService::cancelBundleFor() ist ein einziges Bulk-Update
statt Update-pro-Iteration (UserPaymentOption hat keine Observer/Events).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SetDomainUrl war nirgends registriert und duplizierte die URL-Forcing-
Logik (forceRootUrl/forceScheme/useAssetOrigin/config) aus
ThemeServiceProvider::boot(). Ersatzlos geloescht. Die Host->Config-
Aufloesung ist bereits zentral in DomainAssetContext::resolve gebuendelt
(ThemeMiddleware wurde in 0.4 entfernt, SetCurrentPortal nutzt seit 0.4
Portal::fromTheme).
Bewusst NICHT angefasst: CurrentPortalContext (mutable static) ->
request-scoped Binding - Tests koppeln an ::clear(), Umbau beruehrt viele
Call-Sites (eigener, riskanter Schritt fuer spaeter).
Tests: volle Suite gruen (802 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PressRelease::scopeOwnedByApiUser() (bewusst eng: nur user_id-Autorschaft,
anders als das breitere scopeAccessibleBy) ersetzt die byte-gleiche
findOwnedPressRelease-Query in PressReleaseController + ImageController.
- apiResourceRelations()-Helfer ersetzt den 5x identischen Eager-Load-Set
(company/category.translations/images) in Index/Show/Create/Update/Reclassify.
Bewusst NICHT umgesetzt: Slug-Reimplementierung (uniqueSlug) durch
HasUniqueSlug ersetzen - der API-Fallback Str::random(8) weicht vom
Trait-slugFallback() ab (Verhaltensaenderung bei leer-slugifizierten Titeln).
Tests: neuer Scope-Test; volle Suite gruen (802 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Die 4x identische Zugriffs-Closure (owner_user_id == me OR whereHas('users',
me)) in CompanyController (index/show), PressReleaseController::
findOwnedCompany und CustomerCompanyContext::accessibleCompanyQuery durch
einen Company::scopeAccessibleBy() ersetzt; das Scope-Verhalten
(withoutGlobalScopes) bleibt beim Aufrufer.
Bewusst unangetastet: User::accessibleCompanyIds() (relationenbasiert MIT
globalen Scopes - anderes Scope-Verhalten) und PressReleaseImageController
(prueft PR-Autorschaft user_id, nicht Firmenzugriff).
Tests: neuer Scope-Test; volle Suite gruen (801 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Den ~15x duplizierten Admin-Lookup PressRelease::withoutGlobalScopes()
->findOrFail($id) in einen findAdminPR()-Helfer (Concern) zentralisiert;
index/edit/show nutzen ihn. Die eager-load-Query in show::with() bleibt
unberuehrt (anderer Zweck).
Bewusst NICHT vereinheitlicht: das Status-Badge-Mapping divergiert real
(index: archived/draft -> muted; edit/show: -> hub via default) -> waere
eine Design-Entscheidung ueber kanonische Badge-Farben, keine mechanische
Dedup.
Tests: volle Suite gruen (799 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Die in admin/create und admin/edit identische Inline-Firmensuche
(Volltext-oder-LIKE) inkl. des 2x duplizierten supportsFullTextSearch()-
Treiберchecks in einen Company::scopeSearchByTerm() zentralisiert; beide
Editoren nutzen ihn jetzt (Verhalten unveraendert). Ungenutzte DB-Importe
entfernt. Customer nutzt bereits CustomerCompanyContext (eigene
join-/user-gescopte Query, daher nicht zusammengelegt).
Tests: neuer Scope-Test; volle Suite gruen (799 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Den 3x identischen contentChanged->fresh->reclassifyIfClassified->
rescoreIfScored-Block (admin/edit 1x, customer/edit 2x) in
PressReleaseService::reprocessAfterContentChange() zentralisiert.
Hinweis: Eine vollstaendige createDraft()/updateContent()-Verlagerung
wurde bewusst NICHT erzwungen — die Attributsaetze und der umgebende
Validierungs-/Fehlerfluss von Admin (no_export, direkter review-Status)
und Customer (placeholder_variant, Titel-Fallback, immer Draft) divergieren
real; eine gemeinsame Persistenzmethode waere fragil bei grosser Testflaeche.
Tests: 2 neue Faelle; volle Suite gruen (796 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Erkenntnis: Die Admin-Inline-Query (Contact::withoutGlobalScopes()->where
->orderBy) ist exakt der Contact::forCompany()-Scope -> alle vier
Kontakt-Helfer waren funktional identisch. companyContacts/companyContact/
defaultContactIdFor in neuen Concern InteractsWithPressReleaseContacts
(ueber forCompany) ausgelagert. tagSuggestionsFor() (4x identisch) in
InteractsWithPressReleaseTags verschoben. Ungenutzte Collection-Importe
in den Customer-Editoren entfernt; selectedCompany() bleibt je Kontext
(Admin: withoutGlobalScopes find; Customer: CustomerCompanyContext).
Tests: volle Suite gruen (794 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
presubmitChecks() der vier Editoren in ProvidesPressReleasePresubmitChecks
zentralisiert (war 4x dupliziert mit Drift). Kanonische Version uebernimmt
die bessere Customer-Variante: Firmenname statt leerer Hinweis in der
company-Zeile. Dafuer erhalten die Admin-Editoren ein selectedCompany()
(Company::withoutGlobalScopes find), das auch in with() genutzt wird;
Customer behaelt seine user-gescopte Aufloesung via CustomerCompanyContext.
Tests: volle Suite gruen (794 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Die ~95% identischen Validierungsregeln (Inhaltsfelder, publishMode,
terminabhängiger Scheduling-Block mit zeitzonenbewusster Closure, Embargo)
in baseFormRules() zentralisiert. Die vier Editor-formRules() ergänzen nur
noch die abweichenden Schlüssel (portal nur Admin, companyId mit/ohne
exists). Ungenutzten Rule-Import in customer/edit entfernt.
Tests: volle Suite gruen (794 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Die in allen vier PM-Editoren wortgleiche Scheduling-Maschinerie
(updatedPublishMode/ScheduledDate/ScheduledTime, updated, syncScheduledAt,
resolveScheduledAt, validateScheduledAtWhenReady, notifyValidationError)
in einen gemeinsamen Concern ausgelagert (spiegelt InteractsWith-
PressReleaseTags). ~85 Zeilen je Datei entfernt.
Tests: volle Suite gruen (794 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Konsolidierter Entwicklungsplan aus den zwei Analyse-Dokumenten plus
Umsetzung der Phase-0-Massnahmen (Korrektheit/Security, je mit Tests):
0.1 BasicAuth: timing-sicherer Vergleich (hash_equals, beide Felder ohne
Short-Circuit); Enabled-Check zur Laufzeit via config('auth.basic.*')
statt env() im Bootstrap-Closure (greift jetzt unter config:cache);
Config-Block + .env.example. Fund: auth.basic.* war nie als Config
definiert, Gate war faktisch unkonfigurierbar.
0.2 Legacy-Invoice-"latest"-Abfrage zentralisiert (LegacyInvoice::
scopeLatestFirst + User::latestLegacyInvoice); beide Consumer N+1-frei
umgestellt; unterschiedliches null-Verhalten bewusst erhalten.
0.3 Payment-Option-Eligibility vereinheitlicht (period-aware, strikt):
UserPaymentOption::scopeActiveNow/grandfatheredNow + User::
hasActivePaymentOption; hasActiveBooking/hasUnlimitedPressReleaseQuota
und ApiAccessEligibilityService darauf umgestellt; $hasGrandfathered-
Mislabel behoben.
0.4 Theme->Portal-Vokabular: totes, projektfremdes ThemeMiddleware
geloescht; Mapping in Portal::fromTheme() zentralisiert; fremden
b2in-Default im ThemeServiceProvider auf Portal-Default korrigiert.
0.5 InvoiceNumberGenerator: Erst-Insert race-sicher via insertOrIgnore
(Unique-Index auf circle existiert bereits). CreditWalletService war
bereits abgesichert (Unique-Index + createOrFirst).
Tests: 4 neue Dateien, 5 bestehende auf gueltiges grandfathered_until
angepasst. Volle Suite gruen (793 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Neue JSON-Spalte plans.benefits (Migration + Cast + Plan::benefitsList()).
Im Admin (Tarife & Pakete) pro Tarif zeilenweise pflegbar; die In-App-
Buchungsseite rendert die Liste in den Tarif-Karten statt einer festen
Zeile. Seeder/Factory mit Beispiel-Vorteilen. Single Source of Truth fuer
Buchungsseite und (folgt separat) die oeffentliche Preise-Seite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Themen-Tag-Verwaltung (addTag/removeTag/tagsArray) war in create- und
edit-Editor byte-identisch dupliziert -> Trait
App\Livewire\Concerns\InteractsWithPressReleaseTags.
- Kontaktauswahl je Firma (withoutGlobalScopes + where + Sortierung) aus
create/edit/dashboard in Contact::scopeForCompany() zentralisiert.
- Rechnungsadress-Pflichtfelder als BillingAddress::REQUIRED_FIELDS; isComplete()
und neues completenessPercent() teilen die Liste, Dashboard nutzt es statt
eigener Feldliste.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- User::currentPlan() und hasUnlimitedPressReleaseQuota() pro Request
memoisiert. Beide werden je Render mehrfach abgefragt (Status-Widget auf
jeder Seite, Buchungsseite, Quota-Methoden) und loesten bisher jeweils
eigene subscription-/Plan-/payment-option-Queries aus. fresh() setzt den
Cache zurueck.
- show.blade.php: getMyPR() war NICHT memoisiert und wurde pro Request bis
zu 7x mit voller Eager-Load-Query aufgerufen -> jetzt memoisiert. Firmen-
Kennzahlen direkt per withCount() eager geladen statt nachtraeglichem
loadCount().
- Dupliziertes Zugriffsmuster (withoutGlobalScopes + user_id/company_id) aus
edit/show/index in PressRelease::scopeAccessibleBy(User) zentralisiert
(Single Source of Truth fuer Kunden-PM-Zugriff), mit Tests abgesichert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ein Header-Dropdown oben rechts (Desktop) bzw. im Mobile-Header zeigt
fuer alle Kunden-Panel-User den aktuellen Status: aktuelle Buchung/Tarif,
verbleibendes PM-Kontingent und Credit-Guthaben. Ohne aktive Buchung
weist es direkt auf die noetige Buchung hin und verlinkt die Buchungsseite.
CustomerStatusSummary buendelt die Anzeige-Werte ueber die oeffentliche
User-API (Gating bleibt Single Source of Truth im Modell); die anonyme
Blade-Komponente x-customer.status-menu rendert das Dropdown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bestehende (importierte) Pressemitteilungen speichern ihren Text als
Plaintext mit \n-Umbruechen. Beim Laden in den HTML-Editor (flux:editor)
kollabierten diese zu einem Fliesssatz, weil HTML Whitespace faltet.
Die Plaintext->HTML-Konvertierung (Doppelumbruch = Absatz, Einzelumbruch
= <br>) lag bisher nur im Anzeige-Pfad (render()). Sie ist jetzt als
toEditorHtml() wiederverwendbar und wird beim Laden des Editors genutzt;
die Link-Policy (rel/target) bleibt dabei dem Anzeige-Pfad vorbehalten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Beim Speichern/Veroeffentlichen waren Termine 2 Stunden versetzt: die
App-Zeitzone stand auf UTC, Termine wurden nach UTC umgerechnet, aber
published_at roh angezeigt.
- config/app.php: timezone UTC -> Europe/Berlin
- PressRelease: DISPLAY_TIMEZONE + setTimezone-Umrechnung entfernt
- Admin-/Customer-Formulare: scheduledAtUtc() -> resolveScheduledAt(),
parst direkt in App-Zeitzone; Edit-Hydration ohne setTimezone
- ProofPdfRenderer + Boost-Anzeige: setTimezone entfernt
- Tests an verschiebungsfreies Verhalten angepasst
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end-Aufladung der Credit-Wallet, spiegelt den Einzel-PM-Fluss:
- Paket-Staffel mit Bonus in config/credits.php (10->10, 25->27, 50->55,
100->115 Credits; price_cents netto, Stripe Tax ergaenzt USt.)
- credit_topups (Pending->Paid) + CreditTopupService: startTopup legt den
Pending-Kauf an, fulfill() schreibt die Wallet idempotent gut (credited_at)
- StripeCheckoutService::forCreditTopup via checkoutCharge + credit_topup_id
Metadata; ProcessStripeWebhook schreibt bei checkout.session.completed gut
- Checkout-Route /admin/me/checkout/credits/{pack} mit Rechnungsadress-Gate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ProofPdfService: Veroeffentlichungsnachweis 3 Credits pauschal, einmal pro
PM (Zweitdownload kostenfrei); ProofPdfRenderer erzeugt das PDF on-demand
aus vorhandenen PM-Daten (kein externer Renderer); GET-Download-Endpoint
/admin/me/press-releases/{id}/nachweis hinter downloadProof-Policy + Kauf-Gate
- ExtraPmPurchaseService: tier-gestaffelter Nachkauf (19/15/12/10/8) aus der
Wallet; verbucht als bezahlter SinglePurchase(ExtraPm) und greift damit in
die bestehende Kontingent-/Slot-Mechanik. InsufficientCreditsException
liefert das Mini-Checkout-Signal (required/available/shortfall)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eigener Pruef-Zaehler, getrennt von der Credit-Wallet (Paragraph 4.2/4.3):
- review_checks Ledger (eine Zeile je Pruefung, source free|credit,
charged_credits), aggregiert pro Account/Monat statt pro PM
- ReviewCheckService: Tageslimit (harte Bremse, nicht freikaufbar) ->
Monats-Freikontingent (tier-gestaffelt 4/12/30/60/120) -> Overflow
zieht 1 Credit/Pruefung aus der Wallet
- ReviewLimitException fuer das erreichte Tageslimit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- published_at = Legacy-created_at (Publikationsdatum, Frontend-Sortier-
schlüssel) statt updated_at, das bei Alt-Daten oft den Massen-/Migrations-
stempel trägt und ein falsches "frisches" Datum erzeugte.
- created_at/updated_at per forceFill direkt im Import gesetzt (waren nicht
fillable, wurden still verworfen) – Import allein ist jetzt datumssauber.
- legacy:fix-timestamps korrigiert zusätzlich published_at (status=published).
- PM-UUID bleibt beim Re-Import/Delta-Lauf erhalten (kein Neu-Würfeln).
- MIGRATION-STEPS auf Zwei-Phasen-Strategie umgestellt: migrate, Phase 1 mit
--force, Phase 2 (Delta) ohne --force, Grandfather-Re-Run, Idempotenz-Tabelle.
- Tests: LegacyPressReleaseDateImportTest (published_at-Quelle, Entwurf,
Force-Re-Import erhält UUID + Datum).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Launch-pflichtiger Compliance-Slice: öffentliche Anfrage zu einer PM speist eine
manuelle Admin-Queue (keine KI).
- Migration legal_requests + Model + Enums (Type: dsgvo/personal_rights/report,
Status: open/in_progress/resolved/rejected) + Factory.
- Öffentliches Formular /release/{slug}/rechtliches (LegalRequestController +
web/legal-request.blade.php): typ-abhängiger Hinweistext (Alpine), E-Mail bei
DSGVO/Persönlichkeitsrecht erforderlich, zwei versteckte Honeypot-Felder,
Rate-Limit + Bremse "1 offene Anfrage pro PM/Typ". Regeltexte als Entwurf mit
TODO für rechtliche Finalisierung markiert.
- Routen bewusst in eigener routes/legal.php (entkoppelt vom laufenden Web-Umbau),
host-agnostisch via domains.php eingebunden.
- Admin-Bereich "Recht & Compliance": Sidebar-Nav mit Offen-Zähler, Volt-Queue
index/show (in Bearbeitung/erledigt/abgelehnt/wieder öffnen + interne Notiz).
- Tests: je Typ, Honeypots (Dataset), Bremse, Admin-Queue + Status-Übergänge.
- Doku: Detailplan WS-3-Status + Deployment-Migrationsreihenfolge ergänzt.
Hinweis: Der "Melden"-/E&F-Button auf der PM-Detailseite (release-detail.blade.php)
wird mit dem separaten Web-Frontend-Commit verdrahtet; Ziel ist legal-request.create.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Magic-Link und Pressekontakt-Zugang zu einer Seite (/anmeldelink) zusammengeführt;
altes Login-Modal entfernt, /pressekontakt-zugang leitet weiter.
- ContactAccessService deckt jetzt Firmen-E-Mail UND Pressekontakt-E-Mail ab,
portalübergreifend (ohne PortalScope). Eine E-Mail mehrfach hinterlegt → genau
ein Account, dem alle Firmen + Kontakte zugeordnet werden.
- Zugeordnete Firmen erhalten Pivot-Rolle 'responsible' (Schreibzugriff auf
Stammdaten, Kontakte, Pressemitteilungen) statt nur 'member'; bestehende
Lese-Pivots werden hochgestuft, Owner bleiben unangetastet.
- Neuer Login-Listener (SyncCompanyMembershipsOnLogin) frischt die Zuordnungen
bei JEDEM Login (Magic-Link, Passwort, Google) auf – auch nachträglich (API)
hinzugekommene Firmen/Kontakte mit gleicher E-Mail greifen.
- Auth-Bereich erzwingt Hellmodus: aus dem Portal übernommene .dark-Klasse wird
am <html> entfernt (Login war im Dark Mode hängengeblieben).
- Tests: Firmen-E-Mail-Login, Multi-Firmen-Aggregation, Schreibzugriff/Upgrade,
Per-Login-Re-Sync, Auth-Hellmodus. Sicherheits-Doku aktualisiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RoleAwareLoginResponse gab bei wantsJson() sofort 204 zurück – VOR den
Sicherheitschecks. Ein XHR/JSON-Login eines verifiziert-inaktiven Accounts
erhielt damit eine Session ohne Logout. Checks laufen jetzt zuerst:
verifiziert-inaktiv → Logout + Session-Invalidate + 403 (JSON) bzw. Login mit
Fehler (HTML); unverifiziert → 204 (JSON) bzw. Notice (HTML); danach der
Erfolgsfall.
Tests: JSON-Login eines inaktiven Accounts (403, guest), JSON-Login eines
aktiven Users (204, authentifiziert).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Befund (Review 16.06.): Der Volt-Login machte direkt Auth::attempt() und umging
Fortifys 2FA-Pipeline (2FA-Bypass); zusätzlich existierte der Fortify-POST /login
parallel mit schwächeren Post-Login-Regeln.
Fix (Volt-nativ):
- Volt-Login prüft Credentials ohne sofortiges Login; bei aktivem 2FA wird der
Session-Vertrag login.id/login.remember gesetzt und auf eine neue Volt-
2FA-Challenge-Seite (/two-factor-challenge) geleitet, die an Fortifys
bestehenden Controller postet (TOTP + Recovery-Code).
- Gemeinsame Post-Login-Logik in App\Support\LoginRedirect (rollengerechtes
Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND Response.
- RoleAwareLoginResponse implementiert jetzt LoginResponse UND
TwoFactorLoginResponse und erzwingt einheitlich: unverifiziert → Notice,
verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist
auch der direkte Fortify-POST-Pfad gehärtet.
Tests: 2FA-Übergabe, Challenge-Guard, voller TOTP-Flow, Fortify-POST blockt
inaktive User und hält Customer aus dem Admin-Bereich.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Magic-Link-Versand im Login rate-limited (E-Mail+IP 3/h und IP-only 15/h);
verhindert Mail-Fluten und das Entwerten aktiver Links.
- Inaktive (aber verifizierte) User werden beim Passwort-Login zentral
blockiert (Auth::logout + Fehler) – sichert nur-auth/verified-Routen ab.
- Rollensicherer Login-Redirect: gemerkte intended-Admin-URLs schicken einen
Customer nicht mehr in den 403, sondern auf das rollengerechte Ziel.
- ContactAccess prüft is_active vor jeder Mutation: deaktivierte Bestands-
Accounts werden durch eine Anfrage weder verändert noch angemailt.
- Magic-Link-Verbrauch atomar (UPDATE … whereNull(consumed_at)) – Single-Use
auch bei parallelen Requests.
- Sicherheits-Doku um diese Härtungen + Captcha-Empfehlung ergänzt.
Tests: Rate-Limit, intended-Admin-URL für Customer, inaktiver Login,
ContactAccess ohne Mutation inaktiver Accounts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firmen-Scope (Fundament):
- PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER
Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user-
Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der
Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/
canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten
alle PMs ihrer Firma.
Magic-Link-Zugang für Pressekontakte (ContactAccessService):
- Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit
Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy
angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den
Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet
wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume
wiederverwendet) – keine Schema-Änderung, kein paralleles System.
- Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später).
Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup,
Enumeration-Sicherheit, Honeypot).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
E-Mail-Verifizierung (Entscheidung 15.06.):
- User implementiert MustVerifyEmail; Registrierung legt inaktives, rollenloses
Konto an und leitet auf die Danke-/Notice-Seite; Registered-Event versendet die
Verifizierungsmail. Bestätigter Link aktiviert das Konto + vergibt customer-Rolle
(ActivateUserAfterVerification). Backfill-Migration setzt email_verified_at für
alle Bestands-User (sonst würde die verified-Middleware ~59k aktive Legacy-User
aussperren). Seeder-User verifiziert.
Auth-Flow-Korrekturen:
- Magic-Link-Consume: rollensicherer Redirect ohne intended() (Customer landete
sonst per stale intended=/dashboard im 403-Admin-Bereich).
- Guest-Redirect (bootstrap/app.php) rollen-/verifizierungsbewusst statt fix
/dashboard – schließt die 403-Sackgasse auf /login und /register.
- Logout auf der Notice-Seite via echtes POST-Formular statt Livewire-Action
(behebt 419 beim Session-Invalidate).
- Magic-Link-Anforderung über eigenes Modal mit separater E-Mail-Eingabe.
- Unverifizierte Login-Versuche landen auf der Notice-Seite.
Sicherheitsfix Legacy-Rollen:
- UserImporter mappte Alt-Gruppe 2 (Self-Publisher) auf editor (= Admin-Zugriff).
Mapping auf customer korrigiert; Daten-Migration stuft die 65.950 fälschlichen
Legacy-Editoren auf customer herab. Echte admin/api-only bleiben unberührt.
Tests: Registration, EmailVerification, Authentication (Guest-Redirect),
MagicLinkLogin (Modal/Redirect/Regression), Legacy-Import (Gruppen-Mapping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stripe Tax verlangt eine gültige Kundenadresse. Beide Checkout-Sessions
erfassen jetzt die Rechnungsadresse verpflichtend und speichern sie am
Stripe-Customer (customer_update address/name = auto; Name ist Pflicht
bei aktivierter USt-ID-Abfrage). Zusätzlich liefert User::stripeAddress()
die lokale Rechnungsadresse bei der Customer-Anlage mit (Cashier-Hook).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Einwand/Entscheidung 12.06.2026: Legacy fakturierte brutto (Steuer
inkludiert, z. B. 199 Euro; steuerbefreite Kunden mit Netto-Ausweis
167,23). Alle neuen Preise sind netto; die Steuer wird zur
Rechnungsstellung sauber validiert und ausgewiesen.
- VatResolver + VatTreatment: DE grundsaetzlich immer mit Steuer, EU nur
mit (formal plausibler) USt-ID befreit (Reverse Charge inkl.
Pflichthinweis), Drittlaender grundsaetzlich befreit;
EU-Laenderliste + vat_rate in config/billing.php
- Schema: billing_addresses.vat_id + invoice_billing_addresses.vat_id
(Snapshot pro Rechnung), invoices.tax_note; Profil-Formular schreibt
die vorhandene USt-ID jetzt auch an die Rechnungsadresse
- ManualInvoiceService: rechnet auf Netto-Vertragsbasis
(legacy_conditions.net_cents bzw. Netto-Katalogpreis) und bestimmt
Steuer/is_netto/tax_note pro Rechnung ueber den VatResolver
- legacy:grandfather-subscriptions: leitet net_cents aus der letzten
Legacy-Rechnung ab (brutto / 1,19 bzw. is_netto-Betrag direkt);
fuer DE-Bestandskunden bleibt der Bruttobetrag unveraendert
(199 brutto -> 167,23 netto + 31,77 USt = 199,00)
- Doku: Decision-Update 2.1 (Netto-Klarstellung), Phase-9-Plan,
Checkliste, 05-DATABASE-MERGE 5.6; offen: VIES-Validierung der USt-ID
Tests: VatResolverTest (Datasets fuer alle Faelle), Reverse-Charge/
EU-/Drittland-Rechnungen, Netto-Ableitung; Suite 490 passed, 4 skipped.
Pint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kriterien vom Auftraggeber (12.06.2026): Quelle der Aktiv-Erkennung ist
ausschliesslich das read-only Rechnungsarchiv legacy_invoices (D-12).
Legacy-Rechnungen bleiben Archiv; neue manuelle Rechnungen entstehen im
MAN-Rechnungskreis.
- Aktiv-Regel: juengste Rechnung pro (Portal, Legacy-Vereinbarung) mit
payment_option.type=recurring und user_payment_option.status=active;
next_due_date max. --grace-months (Default 12) ueberfaellig, sonst
stale -> bleibt reines Archiv. Einmal-Kaeufe werden nie uebernommen.
- Uebernahme als grandfathered in user_payment_options:
current_period_end = next_due_date, Betraege/Intervall der letzten
Legacy-Rechnung in legacy_conditions -> der taegliche MAN-Lauf
(billing:generate-manual-invoices) fakturiert zum gewohnten
jaehrlichen Rhythmus weiter. Versteckte Katalog-Platzhalter
LEGACY-{PE|BP}-{Artikel} in payment_options.
- Replay-faehig (D-18): Re-Runs aktualisieren anhand der Legacy-IDs in
legacy_conditions statt zu duplizieren — die Kern-Migration laeuft
kurz vor dem Relaunch erneut.
- Optionen: --dry-run, --as-of, --grace-months, --no-report; JSON-Report
nach storage/app/migration/. Dry-Run gegen Test-Snapshot: 22 aktive
jaehrliche Vereinbarungen, davon 4 sofort faellig, 0 stale.
- Doku: MIGRATION-STEPS.md (Runbook-Reihenfolge nach archive-invoices),
05-DATABASE-MERGE §5.6, 12-NAECHSTE-SCHRITTE 6.6, 08-PROGRESS,
PHASE-9-Plan + Checkliste.
Tests: GrandfatherLegacySubscriptionsTest (7, inkl. End-to-End
Migration -> MAN-Rechnung mit Legacy-Betraegen). Suite: 475 passed,
4 skipped. Pint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tarif-Datenmodell (Decision-Update):
- plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres =
10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder
- single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
Status-Lifecycle und Stripe-Checkout-Referenzen
- laravel/cashier ^16.5 installiert (freigegeben); User ist Billable,
Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation
ueberschreibt bewusst die Cashier-Methode
Hybride Rechnungskreise (Entscheidung 12.06.2026):
- invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende
Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den
manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert
- ManualInvoiceService + billing:generate-manual-invoices (Scheduler
taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne
Stripe-Subscription auf erreichtes Periodenende, friert die
Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus
(Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter;
Konditions-Overrides via legacy_conditions, sonst Netto-Preis +
billing.vat_rate; nicht abrechenbare Faelle werden geloggt und
beim naechsten Lauf erneut geprueft
Submit-Gate:
- User::hasActiveBooking() prueft jetzt echt (hinter
billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf
oder laufende Legacy-Vereinbarung (MAN-Kreis)
Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean.
Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf
Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in
user_payment_options.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>