Der aktive Firmen-Kontext (Firmen-Umschalter) steht jetzt prominent
unter der Überschrift — Firmenname oder „Alle Firmen" — statt als
kleiner Hinweistext. Neue Spalte „Bild" zwischen Status und Titel mit
dem Titelbild (thumb-Variante, is_preview zuerst), dem gewählten
Platzhalter bei bildlosen PMs oder einem generischen Icon-Fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Publish/Reject/Archive und changeStatus erzwingen konsistent
press-releases:publish; changeStatus schloss die Lücke, über die das
Status-Modal die Prüfung umging (Fix#1/H2).
- publish()/changeStatusFromAdmin sperren PM- und User-Zeile
transaktional; QuotaExceededException wird in Admin-Publish und im
Auto-Publish-Job abgefangen (Warnung statt 500 / bleibt in Review, Fix#3/M2).
- Slug-Erzeugung nur noch über HasUniqueSlug (generateUniqueSlug +
saveWithUniqueSlugRetry), redundanter Controller-Helper entfernt (M3).
- API-/Legacy-Text wird vor der Persistenz sanitisiert (M6);
Scheduler/Legacy-Tracking ignorieren soft-gelöschte PMs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Rubrik (lokalisiert) direkt unter den Portal-Pills; Kategorie wird in
accessiblePageFor() eager geladen (kein N+1)
- Status-Spalte: laeuft kein Boost, wirbt eine veroeffentlichte gruene PM
mit "Boost moeglich ->" (Link zur PM-Seite mit den Add-ons)
- Gruen geprueft: kleiner gruener Haken neben dem Titel (Tooltip
"Inhaltspruefung: Gruen")
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5
gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren
Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur
Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost
(12/20/35) erscheint weiter auf Start- UND Branchenseite.
- boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum;
Preise/Slots in config/credits.php (boost_category, boost_slots)
- BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie
zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem
freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern
unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches
- Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit
Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest
- Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar
"Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated);
Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- press_release_events (type view/share, channel, ohne Personenbezug):
Model/Enums/Factory, Cascade-Delete an der PM
- Detailaufruf loggt View-Event zusaetzlich zum Legacy-hits-Zaehler
(bleibt bewusst einfacher Page-Request-Zaehler, User-Vorgabe 10.07.)
- POST press-release/{slug}/share (throttle 30/min, nur archiv-sichtbare
PMs des Portals) loggt Teilen-Klicks je Kanal (LinkedIn/X/E-Mail/Copy)
- Kundenpanel-PM-Seite: neue Kachel Geteilt mit Kanal-Aufschluesselung
- lang: web.detail.views (de/en)
- Tests: Web/PressReleaseTrackingTest (7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kategorie-Pflichtfeld direkt unter die Firma statt in die Sidebar.
Titelbild-Upload öffnet ein Modal: erst Bild wählen, dann erscheinen
die gestrafften Schritte (Herkunft & Bildnachweis mit Vorschlag aus
dem Urheber, Personen & Rechte, Bestätigung), Optionales eingeklappt.
Bildrechte-Checkbox im Einreichen-Modal nur noch mit eigenem Titelbild.
Co-authored-by: Cursor <cursoragent@cursor.com>
Selbstdeklaration des KI-Einsatzes je PM (Text) und je Bild inkl.
Submit-Gate im PressReleaseService, versioniertem Audit-Trail
(press_release_ai_declarations) und öffentlichem KI-Label in
Einreicher-Stimme. Bestätigung im Submit-Modal ist Pflicht.
Co-authored-by: Cursor <cursoragent@cursor.com>
Veröffentlichte PMs zeigten im Backend nur den Status-Badge, ohne Weg
zur öffentlichen Seite. Im Seitenkopf stehen jetzt Direktlinks — bei
Portal::Both einer je Portal (QA-Befund 07.07.).
Die URL-Logik ist als PressRelease::publicPortalLinks() zentralisiert
(vorher doppelt in der Published-Mail und fehlerhaft im Nachweis-PDF:
dort fehlte das /de/press-release/-Segment, die dokumentierte URL lief
ins Leere; bei Both nennt der Nachweis jetzt beide URLs). Edition folgt
der PM-Sprache statt hart „de".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Archiv-Suche lief auf grossen Datenbestaenden langsam und war im Browser
komplett tot: app.js startete ein eigenes Alpine, Livewire injizierte
zusaetzlich seins - der DOM-Morph brach mit "Alpine.transaction is not a
function" ab (keine Rubrik klickbar, Suchbegriff nicht aenderbar).
- app.js buendelt jetzt Livewire + Alpine aus livewire.esm,
@livewireScriptConfig im Web-Layout stoppt die Auto-Injektion
- MySQL-Volltext-Index (Boolean-Mode, +wort*) statt LIKE ueber alles;
LIKE-Fallback fuer SQLite-Tests und kurze Tokens
- Firmen-/Rubrik-Treffer als ID-Listen statt orWhereHas (OR + EXISTS
hebelte den Volltext-Index aus)
Co-authored-by: Cursor <cursoragent@cursor.com>
Kunden koennen eigene veroeffentlichte PMs kostenpflichtig aus der
oeffentlichen Ansicht nehmen: Panel auf der PM-Detailseite (inkl.
T1neu-Unternehmer-Checkbox und Doppelausweis netto/brutto), Stripe-
Ad-hoc-Checkout wie Credit-Topups, Erfuellung im Webhook — die PM
wechselt auf archived + depublished_at, die oeffentliche URL zeigt
dauerhaft einen Tombstone (410 Gone, ohne Inhalte, mit ehrlichem
Drittarchive-Hinweis). Doppelbuchungs-Bremse pro PM; Status-Log mit
Quelle "depublication". Gate: ohne BILLING_DEPUBLICATION_PRICE_CENTS
bleibt der Weg deaktiviert und /melden zeigt die neutrale
Preisformulierung [EXTERN: Preis festlegen, O6].
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vierte Bestaetigung im Einreichungs-Modal (sofortige Ausfuehrung,
Erloeschen des Widerrufsrechts mit Vertragserfuellung); Zeitpunkt wird
beim Submit als withdrawal_waiver_consented_at an der PM protokolliert.
Index-Schnell-Submit (wire:confirm) durch das gemeinsame Modal ersetzt -
der Pfad umging bisher alle rechtlichen Bestaetigungen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Die Uebersicht /admin/me/press-releases war auch fuer leere Konten ~2s: der OR-Filter in scopeAccessibleBy zwang die Paginator-count(*) zum Full-Scan (EXPLAIN type=ALL, ~174k Zeilen) und den Zeilen-Fetch bei 0 Treffern zum vollen created_at-Index-Scan (~1s). Neue PressRelease::accessiblePageFor() nutzt dieselbe index-freundliche UNION wie accessibleStatusCountsFor(): schmale UNION liefert die Seiten-IDs ueber user_id-/company_id-Index, danach nur 25 Modelle hydratisiert; Total via schnellem UNION-count. Messung: leer ~2000ms -> 167ms, groesstes Konto (20.830 PMs) ~740ms -> 468ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Die nach Status gruppierte Zaehlung lief ueber accessibleBy() (user_id OR
company_id IN sub). Der OR-Ausdruck zwingt MySQL zu Full-Scans (EXPLAIN:
companies type=ALL, press_releases Full-Index-Scan ueber 124k Zeilen) und
war mit ~500-950ms der groesste Einzelposten der Seite.
Neue PressRelease::accessibleStatusCountsFor(User, ?Closure): kombiniert
Autor- und Firmen-Zweig als indexfreundliche UNION-Subquery (per PK
dedupliziert) und gruppiert darueber. Im Benchmark ~2,5x schneller bei
identischer Verteilung. Der Firmen-Filter der Uebersicht wird als geteilte
Closure auf Counts UND Liste angewandt (garantiert deckungsgleiche Filterung).
Gesamtmessung /admin/me/press-releases (User 14016, 26.772 Firmen / 20.830
PMs): Query-Pfad ~2850ms -> ~740ms warm; hydratisierte Company-Models 24.
Tests: accessibleStatusCountsFor zaehlt Autor+Firma je einmal (UNION-Dedup)
und wendet Zusatz-Constraints auf beide Zweige an.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scopeAccessibleBy() haengte den Firmenzugriff bisher als whereIn() mit allen
via accessibleCompanyIds() materialisierten Firmen-IDs an. Bei Power-Usern
(26k+ Firmen) ist das ein riesiger IN-Block im SQL.
Jetzt: orWhereIn('company_id', Company::accessibleBy($user)->select('id')) —
MySQL loest die Subquery als Semi-Join ueber den company_id-Index. Gleiche
Semantik (Company-Global-Scopes werden mitkompiliert), aber beschraenkte
SQL-Groesse und keine PHP-seitige ID-Liste mehr.
Benchmark (User 14016): ein korreliertes orWhereHas-EXISTS war ~3x langsamer
(1070ms vs 335ms COUNT) — die Subquery-whereIn-Form ist die schnellste.
accessibleCompanyIds() bleibt fuer canAccessCompany() erhalten.
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>
- 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>
- 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>
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>