diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php index 76b19d3..3678865 100644 --- a/app/Actions/Fortify/PasswordValidationRules.php +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use Illuminate\Contracts\Validation\Rule; use Illuminate\Validation\Rules\Password; trait PasswordValidationRules @@ -9,7 +10,7 @@ trait PasswordValidationRules /** * Get the validation rules used to validate passwords. * - * @return array|string> + * @return array|string> */ protected function passwordRules(): array { diff --git a/app/Console/Commands/GenerateDomainFavicons.php b/app/Console/Commands/GenerateDomainFavicons.php index 2261d0a..adfd238 100644 --- a/app/Console/Commands/GenerateDomainFavicons.php +++ b/app/Console/Commands/GenerateDomainFavicons.php @@ -30,7 +30,7 @@ class GenerateDomainFavicons extends Command $faviconDir = public_path('img/favicons'); // Erstelle das Favicon-Verzeichnis, wenn es nicht existiert - if (!File::exists($faviconDir)) { + if (! File::exists($faviconDir)) { File::makeDirectory($faviconDir, 0755, true); $this->info("Verzeichnis {$faviconDir} erstellt."); } @@ -53,8 +53,9 @@ class GenerateDomainFavicons extends Command $faviconPath = "{$faviconDir}/{$theme}-favicon.ico"; // Wenn die Datei bereits existiert, frage, ob sie überschrieben werden soll - if (File::exists($faviconPath) && !$this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) { + if (File::exists($faviconPath) && ! $this->confirm("Favicon für '{$theme}' existiert bereits. Überschreiben?")) { $this->info("Favicon für '{$theme}' übersprungen."); + continue; } diff --git a/app/Console/Commands/GenerateManualInvoices.php b/app/Console/Commands/GenerateManualInvoices.php new file mode 100644 index 0000000..551ff03 --- /dev/null +++ b/app/Console/Commands/GenerateManualInvoices.php @@ -0,0 +1,70 @@ +option('dry-run'); + $limit = max(1, (int) $this->option('limit')); + + $due = $service->duePaymentOptions(limit: $limit); + + if ($due->isEmpty()) { + $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)); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GrandfatherLegacySubscriptions.php b/app/Console/Commands/GrandfatherLegacySubscriptions.php new file mode 100644 index 0000000..dac9493 --- /dev/null +++ b/app/Console/Commands/GrandfatherLegacySubscriptions.php @@ -0,0 +1,292 @@ +option('dry-run'); + $asOf = $this->option('as-of') ? Carbon::parse($this->option('as-of'))->startOfDay() : today(); + $graceMonths = max(0, (int) $this->option('grace-months')); + $staleBefore = $asOf->copy()->subMonths($graceMonths); + + if ($isDryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + $candidates = $this->collectLatestRecurringAgreements(); + + $report = [ + 'generated_at' => now()->toIso8601String(), + 'as_of' => $asOf->toDateString(), + 'grace_months' => $graceMonths, + 'dry_run' => $isDryRun, + 'created' => [], + 'updated' => [], + 'stale_skipped' => [], + 'immediately_due' => [], + ]; + + foreach ($candidates as $candidate) { + $nextDue = $candidate['next_due_date']; + + if ($nextDue->lessThan($staleBefore)) { + $report['stale_skipped'][] = $this->describe($candidate); + + continue; + } + + if ($nextDue->lessThanOrEqualTo($asOf)) { + $report['immediately_due'][] = $this->describe($candidate); + } + + if ($isDryRun) { + $this->line(sprintf('[dry-run] %s', $this->describe($candidate))); + + continue; + } + + $paymentOption = $this->resolveCatalogOption($candidate); + $existing = $this->findExisting($candidate); + + $attributes = [ + 'user_id' => $candidate['user_id'], + 'payment_option_id' => $paymentOption->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => $candidate['valid_until_date']?->toDateString(), + 'current_period_start' => $candidate['period_start']->toDateString(), + 'current_period_end' => $nextDue->toDateString(), + 'stripe_subscription_id' => null, + 'legacy_conditions' => $candidate['legacy_conditions'], + ]; + + if ($existing) { + $existing->update($attributes); + $report['updated'][] = $this->describe($candidate); + } else { + UserPaymentOption::query()->create($attributes); + $report['created'][] = $this->describe($candidate); + } + } + + $this->table( + ['Ergebnis', 'Anzahl'], + [ + ['Kandidaten (aktiv, recurring)', count($candidates)], + ['Neu angelegt', count($report['created'])], + ['Aktualisiert (Re-Run)', count($report['updated'])], + ['Übersprungen (stale)', count($report['stale_skipped'])], + ['Davon sofort fällig', count($report['immediately_due'])], + ], + ); + + foreach ($report['immediately_due'] as $line) { + $this->warn('Sofort fällig (MAN-Lauf rechnet beim nächsten Lauf ab): '.$line); + } + + if (! $this->option('no-report')) { + $path = sprintf('migration/grandfather-subscriptions-%s.json', now()->format('Ymd-His')); + Storage::put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $this->info("Report: storage/app/{$path}"); + } + + return self::SUCCESS; + } + + /** + * Jüngste Archiv-Rechnung pro (Portal, Legacy-Vereinbarung) mit + * aktiver wiederkehrender Zahlungsoption. + * + * @return array> + */ + private function collectLatestRecurringAgreements(): array + { + $latest = []; + + LegacyInvoice::query() + ->whereNotNull('user_id') + ->whereNotNull('pdf_payload') + ->orderBy('id') + ->chunk(self::CHUNK_SIZE, function ($invoices) use (&$latest): void { + foreach ($invoices as $invoice) { + $payload = $invoice->pdf_payload; + $upo = $payload['user_payment_option'] ?? null; + $option = $payload['payment_option'] ?? null; + + if (! $upo || ! $option) { + continue; + } + + if (($option['type'] ?? null) !== 'recurring' || ($upo['status'] ?? null) !== 'active') { + continue; + } + + $key = $invoice->legacy_portal->value.'#'.$upo['id']; + $current = $latest[$key] ?? null; + + if ($current && $current->invoice_date->greaterThanOrEqualTo($invoice->invoice_date)) { + continue; + } + + $latest[$key] = $invoice; + } + }); + + return array_values(array_map(fn (LegacyInvoice $invoice): array => $this->toCandidate($invoice), $latest)); + } + + /** + * @return array + */ + private function toCandidate(LegacyInvoice $invoice): array + { + $payload = $invoice->pdf_payload; + $snapshot = $invoice->raw_snapshot ?? []; + $upo = $payload['user_payment_option']; + $option = $payload['payment_option']; + + $nextDue = isset($upo['next_due_date']) + ? Carbon::parse($upo['next_due_date'])->startOfDay() + : Carbon::parse($snapshot['service_period_end_date'] ?? $invoice->invoice_date)->startOfDay(); + + $periodStart = isset($snapshot['service_period_begin_date']) + ? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay() + : $nextDue->copy()->subYear(); + + return [ + 'user_id' => $invoice->user_id, + 'legacy_portal' => $invoice->legacy_portal->value, + 'legacy_upo_id' => (int) $upo['id'], + 'article_number' => (string) ($option['article_number'] ?? 'UNBEKANNT'), + 'next_due_date' => $nextDue, + 'period_start' => $periodStart, + 'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date'] + ? Carbon::parse($upo['valid_until_date'])->startOfDay() + : null, + 'legacy_conditions' => [ + 'legacy_portal' => $invoice->legacy_portal->value, + 'legacy_user_payment_option_id' => (int) $upo['id'], + 'legacy_payment_option_id' => (int) ($option['id'] ?? 0), + 'article_number' => $option['article_number'] ?? null, + 'name' => $payload['payment_option_translation']['name'] ?? null, + 'interval' => 'yearly', + 'net_cents' => $this->deriveNetCents($invoice), + 'last_total_cents' => $invoice->total_cents, + 'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false), + 'source_invoice_number' => $invoice->number, + 'source_invoice_date' => $invoice->invoice_date->toDateString(), + ], + ]; + } + + /** + * Netto-Vertragsbasis aus der letzten Legacy-Rechnung. Legacy fakturierte + * brutto (Steuer inkludiert, z. B. 199,00 €); steuerbefreite Kunden + * erhielten den Netto-Ausweis (`is_netto`, z. B. 167,23 €). Die neue + * Rechnungsstellung arbeitet immer auf Netto-Basis — die Steuer wird + * pro Rechnung über den VatResolver bestimmt. + */ + private function deriveNetCents(LegacyInvoice $invoice): int + { + $isNetto = (bool) (($invoice->raw_snapshot ?? [])['is_netto'] ?? false); + + if ($isNetto) { + return $invoice->total_cents; + } + + $vatRate = (float) config('billing.vat_rate', 0.19); + + return (int) round($invoice->total_cents / (1 + $vatRate)); + } + + /** + * Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) — die + * verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions. + */ + private function resolveCatalogOption(array $candidate): PaymentOption + { + $portalShort = $candidate['legacy_portal'] === 'presseecho' ? 'PE' : 'BP'; + $articleNumber = sprintf('LEGACY-%s-%s', $portalShort, $candidate['article_number']); + + return PaymentOption::query()->firstOrCreate( + ['article_number' => $articleNumber], + [ + 'type' => 'recurring', + // Katalogpreise sind netto (Entscheidung 12.06.2026). + 'price_cents' => $candidate['legacy_conditions']['net_cents'], + 'currency' => 'EUR', + 'interval' => 'yearly', + 'is_hidden' => true, + ], + ); + } + + private function findExisting(array $candidate): ?UserPaymentOption + { + return UserPaymentOption::query() + ->where('user_id', $candidate['user_id']) + ->get() + ->first(function (UserPaymentOption $option) use ($candidate): bool { + $conditions = $option->legacy_conditions ?? []; + + return ($conditions['legacy_portal'] ?? null) === $candidate['legacy_portal'] + && (int) ($conditions['legacy_user_payment_option_id'] ?? 0) === $candidate['legacy_upo_id']; + }); + } + + private function describe(array $candidate): string + { + return sprintf( + 'User #%d · %s · Legacy-UPO #%d · fällig %s · netto %s €', + $candidate['user_id'], + $candidate['legacy_portal'], + $candidate['legacy_upo_id'], + $candidate['next_due_date']->toDateString(), + number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'), + ); + } +} diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index fccdc6a..5b01b8e 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; use App\Models\PressRelease; use App\Services\PressRelease\BlacklistViolationException; @@ -11,11 +12,13 @@ use Illuminate\Support\Str; use Throwable; /** - * Veröffentlicht Pressemitteilungen mit Status `review` und einem - * `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde. + * Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation + * `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde. * * Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent: - * berührt nur PRs in Review-Status — bereits publishte werden ignoriert. + * berührt nur grüne PRs in Review-Status — bereits publishte werden ignoriert. + * Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn + * ihr Termin fällig ist. * * Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit * Mail-Benachrichtigung des Autors. @@ -41,8 +44,15 @@ class PublishScheduledPressReleases extends Command $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::withoutGlobalScopes() ->where('status', PressReleaseStatus::Review->value) + ->whereIn('classification', [ + PressReleaseClassification::Green->value, + PressReleaseClassification::Yellow->value, + ]) ->whereNotNull('scheduled_at') ->where('scheduled_at', '<=', $now) ->orderBy('scheduled_at') diff --git a/app/Console/Commands/ResetMonthlyPressReleaseQuota.php b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php new file mode 100644 index 0000000..452e4c6 --- /dev/null +++ b/app/Console/Commands/ResetMonthlyPressReleaseQuota.php @@ -0,0 +1,36 @@ +where('press_release_quota_used_this_month', '>', 0) + ->update(['press_release_quota_used_this_month' => 0]); + + $this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RunClassificationQueue.php b/app/Console/Commands/RunClassificationQueue.php new file mode 100644 index 0000000..f4c0c91 --- /dev/null +++ b/app/Console/Commands/RunClassificationQueue.php @@ -0,0 +1,35 @@ + 'classification', + '--tries' => 3, + ]; + + if ($this->option('once')) { + $options['--once'] = true; + } else { + $options['--stop-when-empty'] = true; + } + + return $this->call('queue:work', $options); + } +} diff --git a/app/Console/Commands/SyncStripePlans.php b/app/Console/Commands/SyncStripePlans.php new file mode 100644 index 0000000..b12e977 --- /dev/null +++ b/app/Console/Commands/SyncStripePlans.php @@ -0,0 +1,140 @@ +error('STRIPE_SECRET ist nicht gesetzt.'); + + return self::FAILURE; + } + + $dryRun = (bool) $this->option('dry-run'); + $stripe = Cashier::stripe(); + + foreach (Plan::query()->active()->get() as $plan) { + if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) { + $this->line("{$plan->slug}: vollständig verknüpft, übersprungen."); + + continue; + } + + if ($dryRun) { + $this->line(sprintf( + '[dry-run] %s: Produkt + Preise %s €/Monat, %s €/Jahr (netto) anlegen.', + $plan->slug, + number_format($plan->monthly_price_cents / 100, 2, ',', '.'), + number_format($plan->yearly_price_cents / 100, 2, ',', '.'), + )); + + continue; + } + + $productId = $plan->stripe_product_id; + + if (! $productId) { + $product = $stripe->products->create([ + 'name' => $plan->name, + 'metadata' => ['plan_slug' => $plan->slug], + ]); + $productId = $product->id; + } + + $monthlyId = $plan->stripe_price_id_monthly + ?: $this->createPrice($stripe, $productId, $plan, $plan->monthly_price_cents, 'month'); + + $yearlyId = $plan->stripe_price_id_yearly + ?: $this->createPrice($stripe, $productId, $plan, $plan->yearly_price_cents, 'year'); + + $plan->update([ + 'stripe_product_id' => $productId, + 'stripe_price_id_monthly' => $monthlyId, + 'stripe_price_id_yearly' => $yearlyId, + ]); + + $this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}"); + } + + $this->syncSinglePmPrice($stripe, $dryRun); + + return self::SUCCESS; + } + + /** + * Legt das Einmal-Produkt „Einzel-Pressemitteilung" an (Netto-Preis aus + * billing.single_pm_price_cents). Die Price-ID landet bewusst in der ENV + * (STRIPE_PRICE_SINGLE_PM) statt in einer Tabelle — es gibt genau einen + * solchen Preis, und ohne ENV bleibt der Checkout deaktiviert. + */ + private function syncSinglePmPrice(object $stripe, bool $dryRun): void + { + if (config('billing.single_pm_stripe_price_id')) { + $this->line('einzel-pm: bereits verknüpft (STRIPE_PRICE_SINGLE_PM), übersprungen.'); + + return; + } + + $amount = (int) config('billing.single_pm_price_cents'); + + if ($dryRun) { + $this->line(sprintf( + '[dry-run] einzel-pm: Einmal-Produkt + Preis %s € (netto) anlegen.', + number_format($amount / 100, 2, ',', '.'), + )); + + return; + } + + $product = $stripe->products->create([ + 'name' => 'Einzel-Pressemitteilung', + 'metadata' => ['purpose' => 'single_pm'], + ]); + + $price = $stripe->prices->create([ + 'product' => $product->id, + 'currency' => 'eur', + 'unit_amount' => $amount, + 'tax_behavior' => 'exclusive', + 'metadata' => ['purpose' => 'single_pm'], + ]); + + $this->info("einzel-pm: {$product->id} · {$price->id}"); + $this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}"); + } + + private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string + { + $price = $stripe->prices->create([ + 'product' => $productId, + 'currency' => strtolower($plan->currency), + 'unit_amount' => $unitAmount, + 'tax_behavior' => 'exclusive', + 'recurring' => ['interval' => $interval], + 'metadata' => ['plan_slug' => $plan->slug], + ]); + + return $price->id; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php deleted file mode 100644 index c75c3f7..0000000 --- a/app/Console/Kernel.php +++ /dev/null @@ -1,36 +0,0 @@ -command('inspire')->hourly(); - } - - /** - * Register the commands for the application. - */ - protected function commands(): void - { - $this->load(__DIR__.'/Commands'); - - require base_path('routes/console.php'); - } -} diff --git a/app/Enums/ImageLicenseType.php b/app/Enums/ImageLicenseType.php new file mode 100644 index 0000000..61d4cf8 --- /dev/null +++ b/app/Enums/ImageLicenseType.php @@ -0,0 +1,80 @@ + 'Eigene Aufnahme', + self::Consent => 'Vom Urheber / Fotografen freigegeben', + self::Commercial => 'Agentur-/Stockbild-Lizenz', + self::CreativeCommons => 'Creative-Commons-Lizenz', + self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe', + self::PublicDomain => 'Gemeinfrei / Public Domain / CC0', + self::AiGenerated => 'KI-generiert (z. B. Midjourney, DALL·E, Firefly)', + self::Other => 'Sonstige Lizenz / Sondervereinbarung', + }; + } + + /** + * Ob für diesen Typ eine Lizenz-URL Pflicht ist. + */ + public function requiresLicenseUrl(): bool + { + return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true); + } + + /** + * Ob zusaetzliche Lizenzdetails verpflichtend sind. + * Bei KI-Bildern ist das Detail das verwendete Tool (AI-Act-Kennzeichnung). + */ + public function requiresLicenseDetail(): bool + { + return in_array($this, [self::CreativeCommons, self::AiGenerated, self::Other], true); + } + + /** + * Rein KI-generierte Bilder haben keinen menschlichen Urheber (§ 2 UrhG); + * maßgeblich sind die Anbieter-Bedingungen und die Kennzeichnungspflicht + * aus Art. 50 EU AI Act (ab 02.08.2026). + */ + public function isAiGenerated(): bool + { + return $this === self::AiGenerated; + } + + /** + * Optionsliste für Selects: value => label. + * + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } +} diff --git a/app/Enums/Portal.php b/app/Enums/Portal.php index 2c56a88..3576599 100644 --- a/app/Enums/Portal.php +++ b/app/Enums/Portal.php @@ -16,4 +16,23 @@ enum Portal: string self::Both => 'Beide Portale', }; } + + public function abbreviation(): string + { + return match ($this) { + self::Presseecho => 'PE', + self::Businessportal24 => 'B24', + self::Both => 'PE+B24', + }; + } + + public static function stripTrailingAbbreviation(string $value): string + { + $abbreviations = implode('|', array_map( + fn (self $portal): string => preg_quote($portal->abbreviation(), '/'), + self::cases(), + )); + + return trim((string) preg_replace('/\s*\(('.$abbreviations.')\)\s*$/u', '', $value)); + } } diff --git a/app/Enums/PressReleaseClassification.php b/app/Enums/PressReleaseClassification.php new file mode 100644 index 0000000..28fd836 --- /dev/null +++ b/app/Enums/PressReleaseClassification.php @@ -0,0 +1,27 @@ + 'Grün', + self::Yellow => 'Gelb', + self::Red => 'Rot', + }; + } +} diff --git a/app/Enums/PressReleaseContentTier.php b/app/Enums/PressReleaseContentTier.php new file mode 100644 index 0000000..83084e0 --- /dev/null +++ b/app/Enums/PressReleaseContentTier.php @@ -0,0 +1,51 @@ += $hochwertig => self::Hochwertig, + $score >= $gepruft => self::Geprueft, + default => self::Standard, + }; + } + + public function label(): string + { + return match ($this) { + self::Standard => 'Standard', + self::Geprueft => 'Geprüft', + self::Hochwertig => 'Hochwertig', + }; + } + + /** + * Ob die Stufe öffentlich als Vertrauensindikator gezeigt wird. Standard + * wird laut Update 2 bewusst nicht beworben (kein Badge/Label). + */ + public function isPubliclyBadged(): bool + { + return $this !== self::Standard; + } +} diff --git a/app/Enums/PressReleasePlaceholder.php b/app/Enums/PressReleasePlaceholder.php new file mode 100644 index 0000000..676a712 --- /dev/null +++ b/app/Enums/PressReleasePlaceholder.php @@ -0,0 +1,90 @@ +.svg`. + */ +enum PressReleasePlaceholder: string +{ + case GridBlue = '01-grid-blue'; + case GridGreen = '02-grid-green'; + case GridAmber = '03-grid-amber'; + case LinesBlue = '04-lines-blue'; + case LinesGreen = '05-lines-green'; + case LinesAmber = '06-lines-amber'; + case DotsBlue = '07-dots-blue'; + case DotsGreen = '08-dots-green'; + case DotsAmber = '09-dots-amber'; + case WavesBlue = '10-waves-blue'; + case WavesGreen = '11-waves-green'; + case WavesAmber = '12-waves-amber'; + case EditorialBlue = '13-editorial-blue'; + case EditorialGreen = '14-editorial-green'; + case EditorialAmber = '15-editorial-amber'; + case SignalBlue = '16-signal-blue'; + case SignalGreen = '17-signal-green'; + case SignalAmber = '18-signal-amber'; + + /** + * Default-Variante, wenn nichts gesetzt ist. + */ + public static function default(): self + { + return self::GridBlue; + } + + /** + * Liefert die Variante zu einem Roh-Wert oder den Default-Fallback. + */ + public static function fromValueOrDefault(?string $value): self + { + return self::tryFrom((string) $value) ?? self::default(); + } + + /** + * Deterministische Variante aus einem Seed (z. B. PM-ID/Titel), damit + * dieselbe PM immer denselben Platzhalter bekommt. + */ + public static function fromSeed(int|string $seed): self + { + $cases = self::cases(); + + return $cases[abs(crc32((string) $seed)) % count($cases)]; + } + + /** + * Öffentlicher Asset-Pfad relativ zu `public/`. + */ + public function path(): string + { + return 'images/press-release-placeholders/'.$this->value.'.svg'; + } + + /** + * Lesbares Label für die UI (Picker-Tooltips etc.). + */ + public function label(): string + { + $pattern = match (true) { + str_contains($this->value, 'grid') => 'Raster', + str_contains($this->value, 'lines') => 'Linien', + str_contains($this->value, 'dots') => 'Punkte', + str_contains($this->value, 'waves') => 'Wellen', + str_contains($this->value, 'editorial') => 'Editorial', + default => 'Signal', + }; + + $color = match (true) { + str_contains($this->value, 'blue') => 'Blau', + str_contains($this->value, 'green') => 'Grün', + default => 'Bernstein', + }; + + return $pattern.' · '.$color; + } +} diff --git a/app/Enums/SinglePurchaseStatus.php b/app/Enums/SinglePurchaseStatus.php new file mode 100644 index 0000000..824416f --- /dev/null +++ b/app/Enums/SinglePurchaseStatus.php @@ -0,0 +1,21 @@ + 'Ausstehend', + self::Paid => 'Bezahlt', + self::Consumed => 'Eingelöst', + self::Refunded => 'Erstattet', + }; + } +} diff --git a/app/Enums/SinglePurchaseType.php b/app/Enums/SinglePurchaseType.php new file mode 100644 index 0000000..3c92f2d --- /dev/null +++ b/app/Enums/SinglePurchaseType.php @@ -0,0 +1,30 @@ + 'Einzel-Pressemitteilung', + self::ExtraPm => 'Extra-PM', + self::Boost => 'Boost / Platzierung', + self::ProofPdf => 'Veröffentlichungsnachweis (PDF)', + }; + } + + /** + * Käufe, die zum Einreichen/Veröffentlichen einer PM berechtigen + * (relevant für das Submit-Gate und den Slot-Verbrauch). + */ + public function grantsSubmission(): bool + { + return in_array($this, [self::SinglePm, self::ExtraPm], true); + } +} diff --git a/app/Enums/VatIdCheckStatus.php b/app/Enums/VatIdCheckStatus.php new file mode 100644 index 0000000..a1a80ae --- /dev/null +++ b/app/Enums/VatIdCheckStatus.php @@ -0,0 +1,19 @@ + 'Inland (Deutschland)', + self::EuConsumer => 'EU ohne USt-ID', + self::ReverseCharge => 'EU mit USt-ID (Reverse Charge)', + self::ThirdCountry => 'Drittland (steuerbefreit)', + }; + } + + public function taxNote(): ?string + { + return match ($this) { + self::ReverseCharge => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, Art. 196 MwStSystRL).', + self::ThirdCountry => 'Nicht im Inland steuerbare Leistung (§ 3a Abs. 2 UStG).', + default => null, + }; + } +} diff --git a/app/Http/Controllers/Api/V1/PressReleaseController.php b/app/Http/Controllers/Api/V1/PressReleaseController.php index 1b565f6..ce561f7 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseController.php @@ -2,12 +2,17 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\PressReleaseStatus; 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\Company; use App\Models\PressRelease; +use App\Services\PressRelease\BlacklistViolationException; +use App\Services\PressRelease\BookingRequiredException; +use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\QuotaExceededException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -46,7 +51,10 @@ class PressReleaseController extends Controller $company->portal->value, $validated['language'], ), - 'status' => $validated['status'] ?? 'draft', + // Ü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. + 'status' => PressReleaseStatus::Draft->value, ]); return PressReleaseResource::make( @@ -101,11 +109,59 @@ class PressReleaseController extends Controller $pressRelease->save(); + // Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen. + if ($pressRelease->wasChanged(['title', 'text'])) { + $service = app(PressReleaseService::class); + $service->reclassifyIfClassified($pressRelease); + $service->rescoreIfScored($pressRelease); + } + return PressReleaseResource::make( $pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) ); } + /** + * Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`. + * + * Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter, + * Statuswechsel, Status-Log und Quota werden über den + * `PressReleaseService` garantiert durchlaufen. `published` bleibt über die + * API unerreichbar. + */ + public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse + { + abort_unless($request->user()->tokenCan('press-releases:write'), 403); + $pressRelease = $this->findOwnedPressRelease($pressRelease, $request); + abort_unless($pressRelease !== null, 403); + + if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) { + return response()->json([ + 'message' => 'Only draft or rejected press releases may be submitted for review.', + ], 409); + } + + try { + $service->submitForReview($pressRelease); + } catch (BookingRequiredException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 402); + } catch (QuotaExceededException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } catch (BlacklistViolationException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } + + return PressReleaseResource::make( + $pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images']) + ); + } + public function destroy(Request $request, int $pressRelease): JsonResponse|Response { abort_unless($request->user()->tokenCan('press-releases:write'), 403); diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..3bc3307 --- /dev/null +++ b/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,104 @@ +active()->where('slug', $planSlug)->firstOrFail(); + $user = $request->user(); + + if (! $user->hasCompleteBillingAddress()) { + return $this->backToProfile(); + } + + if ($user->subscribed()) { + return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.')); + } + + $priceId = $interval === 'yearly' + ? $plan->stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + if (! $priceId) { + return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.')); + } + + return $this->checkout->forSubscription($user, $plan, $interval); + } + + public function singlePm(Request $request): Checkout|RedirectResponse + { + if (! $request->user()->hasCompleteBillingAddress()) { + return $this->backToProfile(); + } + + if (! config('billing.single_pm_stripe_price_id')) { + return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.')); + } + + $purchase = SinglePurchase::query()->create([ + 'user_id' => $request->user()->id, + 'type' => SinglePurchaseType::SinglePm->value, + 'status' => SinglePurchaseStatus::Pending->value, + 'price_cents' => (int) config('billing.single_pm_price_cents'), + 'currency' => 'EUR', + ]); + + return $this->checkout->forSinglePurchase($request->user(), $purchase); + } + + /** + * Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode, + * Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll. + */ + public function billingPortal(Request $request): RedirectResponse + { + $user = $request->user(); + + if (! $user->hasStripeId() || ! $user->subscribed()) { + return $this->backToBookings(__('Es besteht kein aktives Abo, das verwaltet werden könnte.')); + } + + return redirect()->away($this->checkout->billingPortalUrl($user)); + } + + private function backToBookings(string $notice): RedirectResponse + { + return redirect() + ->route('me.bookings.index') + ->with('checkout-notice', $notice); + } + + /** + * Buchungs-Voraussetzung (12.06.2026): ohne vollständige + * Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf + * der Profil-Seite über dem Rechnungsadress-Formular. + */ + private function backToProfile(): RedirectResponse + { + return redirect() + ->route('me.profile') + ->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.')); + } +} diff --git a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php index ef973fd..9f8e56f 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseRequest.php @@ -2,10 +2,8 @@ namespace App\Http\Requests\Api\V1; -use App\Enums\PressReleaseStatus; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StorePressReleaseRequest extends FormRequest { @@ -32,10 +30,6 @@ class StorePressReleaseRequest extends FormRequest 'text' => ['required', 'string'], 'backlink_url' => ['nullable', 'url', 'max:255'], 'keywords' => ['nullable', 'string', 'max:255'], - 'status' => ['nullable', Rule::in([ - PressReleaseStatus::Draft->value, - PressReleaseStatus::Review->value, - ])], 'teaser_begin' => ['nullable', 'integer', 'min:0'], 'teaser_end' => ['nullable', 'integer', 'min:0'], 'no_export' => ['nullable', 'boolean'], diff --git a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php index 7b295c1..d70bd90 100644 --- a/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php +++ b/app/Http/Requests/Api/V1/UpdatePressReleaseRequest.php @@ -2,10 +2,8 @@ namespace App\Http\Requests\Api\V1; -use App\Enums\PressReleaseStatus; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class UpdatePressReleaseRequest extends FormRequest { @@ -32,10 +30,6 @@ class UpdatePressReleaseRequest extends FormRequest 'text' => ['sometimes', 'required', 'string'], 'backlink_url' => ['nullable', 'url', 'max:255'], 'keywords' => ['nullable', 'string', 'max:255'], - 'status' => ['sometimes', Rule::in([ - PressReleaseStatus::Draft->value, - PressReleaseStatus::Review->value, - ])], 'teaser_begin' => ['nullable', 'integer', 'min:0'], 'teaser_end' => ['nullable', 'integer', 'min:0'], 'no_export' => ['nullable', 'boolean'], diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php new file mode 100644 index 0000000..a659cca --- /dev/null +++ b/app/Jobs/ClassifyPressRelease.php @@ -0,0 +1,107 @@ +find($this->pressReleaseId); + + if ($pressRelease === null) { + return; + } + + $result = $this->classify($manager, $pressRelease); + + $pressRelease->forceFill([ + 'classification' => $result->classification->value, + 'classified_at' => now(), + ])->save(); + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => $result->classification->value, + 'reason' => $result->reasonText(), + 'raw_response' => $result->rawResponse, + 'created_at' => now(), + ]); + + if (! $this->route) { + return; + } + + try { + $service->routeByClassification($pressRelease, $result->classification, $result->reasonText()); + } catch (BlacklistViolationException) { + // publish() hat die PM bereits abgelehnt und den Autor benachrichtigt. + } + } + + /** + * Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei + * Fehler greift der deterministische Fallback, damit das Ergebnis + * nachvollziehbar bleibt. + */ + private function classify(ClassificationManager $manager, PressRelease $pressRelease): ClassificationResult + { + $provider = $this->providerOverride ?: $manager->getDefaultDriver(); + + try { + return $manager->driver($provider)->classify($pressRelease); + } catch (\Throwable $exception) { + Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [ + 'press_release_id' => $pressRelease->id, + 'provider' => $provider, + 'error' => $exception->getMessage(), + ]); + + return $manager->driver('deterministic')->classify($pressRelease); + } + } +} diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php new file mode 100644 index 0000000..c2d97ac --- /dev/null +++ b/app/Jobs/ScorePressRelease.php @@ -0,0 +1,85 @@ +find($this->pressReleaseId); + + if ($pressRelease === null) { + return; + } + + $result = $this->score($manager, $pressRelease); + $tier = PressReleaseContentTier::fromScore($result->score); + + $pressRelease->forceFill([ + 'content_score' => $result->score, + 'content_tier' => $tier->value, + 'scored_at' => now(), + ])->save(); + + KiAudit::query()->create([ + 'press_release_id' => $pressRelease->id, + 'type' => KiAudit::TYPE_CONTENT_SCORE, + 'provider' => $result->provider, + 'model' => $result->model, + 'result' => (string) $result->score, + 'reason' => $tier->label(), + 'raw_response' => $result->rawResponse, + 'created_at' => now(), + ]); + } + + /** + * Bewertet über den aktiven (oder explizit gewählten) Treiber; bei Fehler + * greift der deterministische Fallback. + */ + private function score(ContentScoreManager $manager, PressRelease $pressRelease): ContentScoreResult + { + $provider = $this->providerOverride ?: $manager->getDefaultDriver(); + + try { + return $manager->driver($provider)->score($pressRelease); + } catch (\Throwable $exception) { + Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [ + 'press_release_id' => $pressRelease->id, + 'provider' => $provider, + 'error' => $exception->getMessage(), + ]); + + return $manager->driver('deterministic')->score($pressRelease); + } + } +} diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php new file mode 100644 index 0000000..5200c7f --- /dev/null +++ b/app/Listeners/ProcessStripeWebhook.php @@ -0,0 +1,146 @@ +payload['type'] ?? null) { + 'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []), + 'checkout.session.completed' => $this->fulfillSinglePurchase($event->payload['data']['object'] ?? []), + default => null, + }; + } + + /** + * @param array $stripeInvoice + */ + private function mirrorPaidInvoice(array $stripeInvoice): void + { + $stripeInvoiceId = $stripeInvoice['id'] ?? null; + + if (! $stripeInvoiceId) { + return; + } + + // Idempotent: Stripe liefert Webhooks mindestens einmal. + if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) { + return; + } + + $user = Cashier::findBillable($stripeInvoice['customer'] ?? null); + + if (! $user instanceof User) { + Log::warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [ + 'stripe_invoice_id' => $stripeInvoiceId, + 'stripe_customer' => $stripeInvoice['customer'] ?? null, + ]); + + return; + } + + $subtotal = (int) ($stripeInvoice['subtotal'] ?? 0); + $tax = (int) ($stripeInvoice['tax'] ?? 0); + $total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax); + + $invoice = Invoice::query()->create([ + 'user_id' => $user->id, + 'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id, + 'number' => $this->numbers->nextStripeNumber(), + 'status' => InvoiceStatus::Paid->value, + 'amount_cents' => $subtotal, + 'tax_cents' => $tax, + 'total_cents' => $total, + 'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')), + 'is_netto' => $tax === 0, + 'invoice_date' => now()->toDateString(), + 'paid_at' => now(), + 'stripe_invoice_id' => $stripeInvoiceId, + ]); + + Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [ + 'number' => $invoice->number, + 'stripe_invoice_id' => $stripeInvoiceId, + ]); + } + + /** + * Adress-Snapshot pro Rechnung: bevorzugt die Adresse aus dem + * Stripe-Payload (maßgeblich für genau diese Rechnung), sonst die + * lokale Rechnungsadresse des Users. + * + * @param array $stripeInvoice + */ + private function snapshotAddress(User $user, array $stripeInvoice): InvoiceBillingAddress + { + $stripeAddress = $stripeInvoice['customer_address'] ?? null; + $local = $user->billingAddress; + + return InvoiceBillingAddress::query()->create([ + 'company' => $local?->company, + 'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name, + 'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '', + 'address2' => $stripeAddress['line2'] ?? $local?->address2, + 'postal_code' => $stripeAddress['postal_code'] ?? $local?->postal_code ?? '', + 'city' => $stripeAddress['city'] ?? $local?->city ?? '', + 'country_code' => $stripeAddress['country'] ?? $local?->country_code ?? 'DE', + 'vat_id' => $local?->vat_id, + ]); + } + + /** + * @param array $session + */ + private function fulfillSinglePurchase(array $session): void + { + $purchaseId = $session['metadata']['single_purchase_id'] ?? null; + + if (! $purchaseId) { + return; + } + + $purchase = SinglePurchase::query()->find((int) $purchaseId); + + if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) { + return; + } + + $purchase->update([ + 'status' => SinglePurchaseStatus::Paid->value, + 'paid_at' => now(), + 'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id, + 'stripe_payment_intent_id' => $session['payment_intent'] ?? null, + ]); + + Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); + } +} diff --git a/app/Models/BillingAddress.php b/app/Models/BillingAddress.php index d81b2da..5d16f2a 100644 --- a/app/Models/BillingAddress.php +++ b/app/Models/BillingAddress.php @@ -14,16 +14,33 @@ class BillingAddress extends Model 'user_id', 'salutation_key', 'title', + 'company', + 'first_name', + 'last_name', 'name', 'address1', 'address2', 'postal_code', 'city', 'country_code', + 'vat_id', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } + + /** + * Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung? + * Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land. + */ + public function isComplete(): bool + { + return filled($this->name) + && filled($this->address1) + && filled($this->postal_code) + && filled($this->city) + && filled($this->country_code); + } } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index e34c078..8bcfb71 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -23,6 +23,7 @@ class Invoice extends Model 'total_cents', 'currency', 'is_netto', + 'tax_note', 'invoice_date', 'due_date', 'paid_at', diff --git a/app/Models/InvoiceBillingAddress.php b/app/Models/InvoiceBillingAddress.php index 3054856..1680fe8 100644 --- a/app/Models/InvoiceBillingAddress.php +++ b/app/Models/InvoiceBillingAddress.php @@ -13,12 +13,14 @@ class InvoiceBillingAddress extends Model protected $fillable = [ 'salutation_key', 'title', + 'company', 'name', 'address1', 'address2', 'postal_code', 'city', 'country_code', + 'vat_id', ]; public function invoices(): HasMany diff --git a/app/Models/KiAudit.php b/app/Models/KiAudit.php new file mode 100644 index 0000000..cbb051e --- /dev/null +++ b/app/Models/KiAudit.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + public $timestamps = false; + + public const TYPE_CLASSIFICATION = 'classification'; + + public const TYPE_CONTENT_SCORE = 'content_score'; + + protected $fillable = [ + 'press_release_id', + 'type', + 'provider', + 'model', + 'result', + 'reason', + 'raw_response', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'raw_response' => 'array', + 'created_at' => 'datetime', + ]; + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..94af782 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,51 @@ + 'integer', + 'yearly_price_cents' => 'integer', + 'press_release_quota' => 'integer', + 'daily_limit' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true)->orderBy('sort_order'); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 26ec88b..3f4e5ad 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -3,6 +3,9 @@ namespace App\Models; use App\Enums\Portal; +use App\Enums\PressReleaseClassification; +use App\Enums\PressReleaseContentTier; +use App\Enums\PressReleasePlaceholder; use App\Enums\PressReleaseStatus; use App\Models\Concerns\HasUniqueSlug; use App\Scopes\PortalScope; @@ -14,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; use Illuminate\Support\HtmlString; class PressRelease extends Model @@ -21,6 +25,13 @@ class PressRelease extends Model /** @use HasFactory */ use HasFactory, HasUniqueSlug, SoftDeletes; + /** + * Anzeige-Zeitzone für vom Nutzer erfasste Termine (scheduled_at, + * embargo_at). In der Datenbank wird weiterhin UTC gespeichert + * (config app.timezone). + */ + public const DISPLAY_TIMEZONE = 'Europe/Berlin'; + /** * @return list */ @@ -37,6 +48,13 @@ class PressRelease extends Model protected static function booted(): void { static::addGlobalScope(new PortalScope); + + static::creating(function (self $pressRelease): void { + if (blank($pressRelease->placeholder_variant)) { + $seed = $pressRelease->uuid ?? $pressRelease->title ?? (string) now()->timestamp; + $pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed)->value; + } + }); } protected $fillable = [ @@ -51,9 +69,15 @@ class PressRelease extends Model 'slug', 'text', 'boilerplate_override', + 'placeholder_variant', 'backlink_url', 'keywords', 'status', + 'classification', + 'classified_at', + 'content_score', + 'content_tier', + 'scored_at', 'hits', 'teaser_begin', 'teaser_end', @@ -69,7 +93,13 @@ class PressRelease extends Model { return [ 'portal' => Portal::class, + 'placeholder_variant' => PressReleasePlaceholder::class, 'status' => PressReleaseStatus::class, + 'classification' => PressReleaseClassification::class, + 'classified_at' => 'datetime', + 'content_score' => 'integer', + 'content_tier' => PressReleaseContentTier::class, + 'scored_at' => 'datetime', 'hits' => 'integer', 'teaser_begin' => 'integer', 'teaser_end' => 'integer', @@ -81,6 +111,22 @@ class PressRelease extends Model ]; } + /** + * Geplanter Veröffentlichungstermin in der Anzeige-Zeitzone (Europe/Berlin). + */ + public function scheduledAtLocal(): ?Carbon + { + return $this->scheduled_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE); + } + + /** + * Sperrfrist (Embargo) in der Anzeige-Zeitzone (Europe/Berlin). + */ + public function embargoAtLocal(): ?Carbon + { + return $this->embargo_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -116,6 +162,11 @@ class PressRelease extends Model return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); } + public function kiAudits(): HasMany + { + return $this->hasMany(KiAudit::class)->orderByDesc('created_at'); + } + /** * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and *

/
-wrapped legacy plain text for older imports. diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 5207759..391dae7 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ImageLicenseType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; @@ -19,6 +20,17 @@ class PressReleaseImage extends Model 'title', 'description', 'copyright', + 'author', + 'license_type', + 'license_detail', + 'license_url', + 'source_url', + 'persons_consent', + 'people_rights_status', + 'property_rights_status', + 'rights_notes', + 'rights_confirmed_at', + 'is_ai_generated', 'is_preview', 'sort_order', 'width', @@ -32,6 +44,10 @@ class PressReleaseImage extends Model { return [ 'variants' => 'array', + 'license_type' => ImageLicenseType::class, + 'persons_consent' => 'boolean', + 'rights_confirmed_at' => 'datetime', + 'is_ai_generated' => 'boolean', 'is_preview' => 'boolean', 'sort_order' => 'integer', 'width' => 'integer', diff --git a/app/Models/SinglePurchase.php b/app/Models/SinglePurchase.php new file mode 100644 index 0000000..6fb079b --- /dev/null +++ b/app/Models/SinglePurchase.php @@ -0,0 +1,68 @@ + SinglePurchaseType::class, + 'status' => SinglePurchaseStatus::class, + 'price_cents' => 'integer', + 'paid_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + /** + * Bezahlte, noch nicht eingelöste Käufe, die zum Einreichen berechtigen. + */ + public function scopeGrantingSubmission(Builder $query): Builder + { + return $query + ->where('status', SinglePurchaseStatus::Paid->value) + ->whereIn('type', [ + SinglePurchaseType::SinglePm->value, + SinglePurchaseType::ExtraPm->value, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e4abec7..7784a95 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\Portal; use App\Enums\RegistrationType; +use App\Enums\UserPaymentOptionStatus; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; @@ -21,7 +23,7 @@ use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory */ - use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; + use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -43,6 +45,7 @@ class User extends Authenticatable 'legacy_portal', 'legacy_id', 'password', + 'press_release_quota_used_this_month', ]; /** @@ -73,9 +76,155 @@ class User extends Authenticatable 'last_seen_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', + 'press_release_quota_used_this_month' => 'integer', ]; } + /** + * Adresse für die Stripe-Customer-Anlage (Cashier-Hook). Stripe Tax + * braucht eine gültige Kundenadresse — falls lokal eine + * Rechnungsadresse gepflegt ist, wird sie direkt mitgegeben; sonst + * speichert der Checkout die dort erfasste Adresse (customer_update). + * + * @return array|null + */ + public function stripeAddress(): ?array + { + $address = $this->billingAddress; + + if (! $address) { + return null; + } + + return [ + 'line1' => $address->address1, + 'line2' => $address->address2, + 'postal_code' => $address->postal_code, + 'city' => $address->city, + 'country' => $address->country_code, + ]; + } + + /** + * Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und + * Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse. + */ + public function hasCompleteBillingAddress(): bool + { + return (bool) $this->billingAddress?->isComplete(); + } + + /** + * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans` + * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo. + */ + public function currentPlan(): ?Plan + { + $subscription = $this->subscription(); + + if (! $subscription?->valid()) { + return null; + } + + $priceId = $subscription->stripe_price; + + if (! $priceId) { + return null; + } + + return Plan::query() + ->where('stripe_price_id_monthly', $priceId) + ->orWhere('stripe_price_id_yearly', $priceId) + ->first(); + } + + /** + * Hat dieser User ein unbegrenztes PM-Kontingent? + * + * Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered + * Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert — + * das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter + * `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden. + */ + public function hasUnlimitedPressReleaseQuota(): bool + { + if (! config('billing.enforce_booking')) { + return true; + } + + return $this->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->exists(); + } + + /** + * Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus + * bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe. + * Null bedeutet unbegrenzt. + */ + public function pressReleaseQuotaRemaining(): ?int + { + if ($this->hasUnlimitedPressReleaseQuota()) { + return null; + } + + $planRemaining = max( + 0, + ($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month, + ); + + return $planRemaining + $this->singlePurchases()->grantingSubmission()->count(); + } + + /** + * Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe) + * für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt. + */ + public function pressReleaseQuotaTotal(): ?int + { + if ($this->hasUnlimitedPressReleaseQuota()) { + return null; + } + + return ($this->currentPlan()?->press_release_quota ?? 0) + + $this->singlePurchases()->grantingSubmission()->count(); + } + + /** + * Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung + * erfordert eine aktive Buchung. + * + * Hybrides Modell: Eine Buchung ist entweder ein aktives Stripe-Abo + * (Cashier, STR-Kreis), ein bezahlter Einmalkauf (Einzel-PM/Extra-PM) + * oder eine laufende Legacy-Zahlungsvereinbarung (manueller MAN-Kreis). + * Solange `billing.enforce_booking` deaktiviert ist, bleibt das Gate + * offen (Launch-Schalter). + */ + public function hasActiveBooking(): bool + { + if (! config('billing.enforce_booking')) { + return true; + } + + if ($this->subscribed()) { + return true; + } + + if ($this->singlePurchases()->grantingSubmission()->exists()) { + return true; + } + + return $this->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->exists(); + } + /** * Get the user's initials */ @@ -135,6 +284,16 @@ class User extends Authenticatable return $this->hasMany(UserPaymentOption::class); } + public function singlePurchases(): HasMany + { + return $this->hasMany(SinglePurchase::class); + } + + /** + * Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die + * gleichnamige Cashier-Methode — Stripe-Rechnungen werden beim + * Webhook-Sync (9E) in diese Tabelle gespiegelt. + */ public function invoices(): HasMany { return $this->hasMany(Invoice::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78558cf..51de0bb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,6 +22,7 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Laravel\Cashier\Cashier; use Livewire\Livewire; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -51,6 +52,12 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + // Stripe Tax berechnet die USt im Checkout automatisch nach den + // gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer, + // EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich + // die USt-ID-Abfrage im Stripe Checkout. + Cashier::calculateTaxes(); + AdminPreset::observe(AdminPerformanceCacheObserver::class); Category::observe(AdminPerformanceCacheObserver::class); CategoryTranslation::observe(AdminPerformanceCacheObserver::class); diff --git a/app/Providers/ThemeServiceProvider.php b/app/Providers/ThemeServiceProvider.php index 9f34a45..326d70d 100644 --- a/app/Providers/ThemeServiceProvider.php +++ b/app/Providers/ThemeServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Support\DomainAssetContext; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\URL; @@ -16,7 +17,6 @@ class ThemeServiceProvider extends ServiceProvider */ public function register(): void { - // Registriere die domains.php Konfigurationsdatei $this->mergeConfigFrom( base_path('config/domains.php'), 'domains' @@ -28,11 +28,10 @@ class ThemeServiceProvider extends ServiceProvider */ public function boot(): void { - $host = Request::getHost(); // is domain_name - $themeOverride = Request::get('theme'); // Allow theme override via URL parameter + $host = Request::getHost(); + $themeOverride = Request::get('theme'); - // Standard-Werte für Domain, die nicht in der Konfiguration sind - $domainConfig = [ + $defaults = [ 'name' => config('app.name'), 'theme' => 'b2in', 'view_prefix' => 'b2in', @@ -41,68 +40,47 @@ class ThemeServiceProvider extends ServiceProvider 'domain_name' => config('app.domain_name'), ]; - // Lade die Domain-Konfiguration - $confiDomains = config('domains.domains'); + $domainConfig = DomainAssetContext::resolve( + $host, + $defaults, + config('domains.domains', []), + is_string($themeOverride) ? $themeOverride : null, + ); - // Suche nach der aktuellen Domain in der Konfiguration - foreach ($confiDomains as $name => $config) { - if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) { - $domainConfig = array_merge($domainConfig, $config); - break; - } - } + $staticAssetOrigin = DomainAssetContext::staticAssetOrigin($domainConfig); + $viteDevServerUrl = DomainAssetContext::viteDevServerUrl($domainConfig); - // Allow theme override via URL parameter (for testing) - if ($themeOverride && isset($confiDomains[$themeOverride])) { - $domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]); - } - - // Dynamische ASSET_URL basierend auf der aktuellen Domain setzen - // Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden - $assetUrl = $domainConfig['url']; - - // Grundlegende Konfiguration im Anwendungskontext verfügbar machen config([ 'app.theme' => $domainConfig['theme'], 'app.view_prefix' => $domainConfig['view_prefix'], 'app.domain_name' => $domainConfig['domain_name'], 'app.url' => $domainConfig['url'], - 'app.asset_url' => $assetUrl, // Dynamische Asset-URL für die aktuelle Domain + 'app.asset_url' => $staticAssetOrigin, ]); - // URL-Generator für die aktuelle Domain konfigurieren - // Dies ist wichtig, damit asset() und url() die richtige Domain verwenden URL::forceRootUrl($domainConfig['url']); URL::forceScheme(parse_url($domainConfig['url'], PHP_URL_SCHEME) ?: 'https'); - // WICHTIG: Asset-Root direkt im UrlGenerator setzen - // Der asset() Helper verwendet einen separaten Asset-Root /** @var UrlGenerator $urlGenerator */ $urlGenerator = app('url'); - $urlGenerator->useAssetOrigin($assetUrl); + $urlGenerator->useAssetOrigin($staticAssetOrigin); - // Spezifischere Daten für die Views verfügbar machen View::share('theme', $domainConfig['theme']); View::share('viewPrefix', $domainConfig['view_prefix']); View::share('domainName', $domainConfig['domain_name']); View::share('domainConfig', $domainConfig); View::share('domainUrl', $domainConfig['url']); - View::share('assetUrl', $assetUrl); + View::share('assetUrl', $staticAssetOrigin); + View::share('viteDevServerUrl', $viteDevServerUrl); - // Vite-Assets-Konfiguration für die aktuelle Domain if (! app()->runningInConsole()) { if (isset($domainConfig['assets_dir'])) { - Vite::useBuildDirectory($domainConfig['assets_dir']); + DomainAssetContext::configureVite($domainConfig); } if (app()->environment('local')) { - // Entwicklung: Vite Dev Server mit HMR - $viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.pressekonto.test'); - Vite::useHotFile(public_path('hot')); config(['app.vite_dev_server_url' => $viteDevServerUrl]); - View::share('viteDevServerUrl', $viteDevServerUrl); } else { - // Produktion: Assets von der aktuellen Domain laden (kein CORS nötig) Vite::useScriptTagAttributes(['crossorigin' => false]); Vite::useStyleTagAttributes(['crossorigin' => false]); } diff --git a/app/Services/Billing/InvoiceNumberGenerator.php b/app/Services/Billing/InvoiceNumberGenerator.php new file mode 100644 index 0000000..9e3f4e9 --- /dev/null +++ b/app/Services/Billing/InvoiceNumberGenerator.php @@ -0,0 +1,69 @@ +next(self::CIRCLE_STRIPE); + } + + public function nextManualNumber(): string + { + return $this->next(self::CIRCLE_MANUAL); + } + + public function next(string $circle): string + { + if (! in_array($circle, [self::CIRCLE_STRIPE, self::CIRCLE_MANUAL], true)) { + throw new InvalidArgumentException("Unbekannter Rechnungskreis: {$circle}"); + } + + $number = DB::transaction(function () use ($circle): int { + $sequence = DB::table('invoice_number_sequences') + ->where('circle', $circle) + ->lockForUpdate() + ->first(); + + if (! $sequence) { + DB::table('invoice_number_sequences')->insert([ + 'circle' => $circle, + 'next_number' => 2, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 1; + } + + DB::table('invoice_number_sequences') + ->where('id', $sequence->id) + ->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]); + + return (int) $sequence->next_number; + }); + + $padding = (int) config('billing.invoice_number_padding', 5); + + return sprintf('%s-%s', $circle, str_pad((string) $number, $padding, '0', STR_PAD_LEFT)); + } +} diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php new file mode 100644 index 0000000..8b11b23 --- /dev/null +++ b/app/Services/Billing/ManualInvoiceService.php @@ -0,0 +1,162 @@ + + */ + public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50): Collection + { + $asOf = $asOf ?? today(); + + return UserPaymentOption::query() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->whereNull('stripe_subscription_id') + ->whereDate('current_period_end', '<=', $asOf) + ->orderBy('current_period_end') + ->limit($limit) + ->with(['user.billingAddress', 'paymentOption']) + ->get(); + } + + /** + * Stellt die fällige Rechnung für eine Vereinbarung aus und schaltet die + * Periode weiter. Gibt die Rechnung zurück oder null, wenn die + * Vereinbarung (noch) nicht abrechenbar ist — dann bleibt die Periode + * unverändert und der nächste Lauf versucht es erneut. + */ + public function invoiceFor(UserPaymentOption $option, ?Carbon $asOf = null): ?Invoice + { + $asOf = $asOf ?? today(); + + $user = $option->user; + $interval = $this->billingInterval($option); + + if (! $user) { + Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $option->id]); + + return null; + } + + if (! $interval) { + Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $option->id]); + + return null; + } + + $billingAddress = $user->billingAddress; + + if (! $billingAddress) { + Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [ + 'user_payment_option_id' => $option->id, + 'user_id' => $user->id, + ]); + + return null; + } + + $netCents = $this->resolveNetCents($option); + + // USt zur Rechnungsstellung bestimmen: DE immer mit Steuer, EU nur + // mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit. + $treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id); + $taxCents = $this->vat->taxCentsFor($netCents, $treatment); + + return DB::transaction(function () use ($option, $user, $billingAddress, $netCents, $taxCents, $treatment, $interval, $asOf): Invoice { + // Adresse pro Rechnung einfrieren (Snapshot-Tabelle). + $addressSnapshot = InvoiceBillingAddress::query()->create([ + 'salutation_key' => $billingAddress->salutation_key, + 'title' => $billingAddress->title, + 'company' => $billingAddress->company, + 'name' => $billingAddress->name, + 'address1' => $billingAddress->address1, + 'address2' => $billingAddress->address2, + 'postal_code' => $billingAddress->postal_code, + 'city' => $billingAddress->city, + 'country_code' => $billingAddress->country_code, + 'vat_id' => $billingAddress->vat_id, + ]); + + $invoice = Invoice::query()->create([ + 'user_id' => $user->id, + 'invoice_billing_address_id' => $addressSnapshot->id, + 'number' => $this->numbers->nextManualNumber(), + 'status' => InvoiceStatus::Open->value, + 'amount_cents' => $netCents, + 'tax_cents' => $taxCents, + 'total_cents' => $netCents + $taxCents, + 'currency' => $option->paymentOption?->currency ?? 'EUR', + 'is_netto' => $treatment->isTaxExempt(), + 'tax_note' => $treatment->taxNote(), + 'invoice_date' => $asOf, + 'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)), + ]); + + $option->update([ + 'current_period_start' => $option->current_period_end, + 'current_period_end' => $interval === 'yearly' + ? $option->current_period_end->copy()->addYear() + : $option->current_period_end->copy()->addMonth(), + ]); + + return $invoice; + }); + } + + private function billingInterval(UserPaymentOption $option): ?string + { + $interval = $option->legacy_conditions['interval'] + ?? $option->paymentOption?->interval; + + return in_array($interval, ['monthly', 'yearly'], true) ? $interval : null; + } + + /** + * Netto-Vertragsbasis der Vereinbarung. Alle neuen Preise sind netto; + * für Grandfathered-Vereinbarungen liefert die Migration `net_cents` + * (aus den Brutto-/Netto-Beträgen der letzten Legacy-Rechnung). + */ + private function resolveNetCents(UserPaymentOption $option): int + { + $conditions = $option->legacy_conditions ?? []; + + return (int) ($conditions['net_cents'] ?? $option->paymentOption?->price_cents ?? 0); + } +} diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php new file mode 100644 index 0000000..c25b853 --- /dev/null +++ b/app/Services/Billing/StripeCheckoutService.php @@ -0,0 +1,120 @@ +stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + $this->syncTaxIdFromBillingAddress($user); + + return $user + ->newSubscription('default', $priceId) + ->checkout($this->sessionOptions()); + } + + /** + * Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer + * übergeben (User-Panel-Restarbeiten, 12.06.2026) — Stripe Tax + * berücksichtigt sie dann ohne erneute Eingabe im Checkout. Fehler + * (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht: + * Stripe validiert die im Checkout erfasste ID ohnehin selbst. + */ + private function syncTaxIdFromBillingAddress(User $user): void + { + $vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id)); + + if ($vatId === '') { + return; + } + + $prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2); + + if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) { + return; + } + + try { + $user->createOrGetStripeCustomer(); + + $alreadySet = collect($user->taxIds()) + ->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId); + + if (! $alreadySet) { + $user->createTaxId('eu_vat', $vatId); + } + } catch (\Throwable $exception) { + Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [ + 'user_id' => $user->id, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige + * Kundenadresse — die im Checkout erfasste Rechnungsadresse (und der + * Name, Pflicht bei USt-ID-Abfrage) wird darum am Stripe-Customer + * gespeichert (`customer_update: auto`). + * + * @return array + */ + private function sessionOptions(): array + { + return [ + 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), + 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), + 'billing_address_collection' => 'required', + 'customer_update' => [ + 'address' => 'auto', + 'name' => 'auto', + ], + ]; + } + + /** + * URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung). + * Rücksprung auf die Buchungs-Seite. + */ + public function billingPortalUrl(User $user): string + { + return $user->billingPortalUrl(route('me.bookings.index')); + } + + /** + * Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den + * Session-Metadaten schließt den Kreis: `checkout.session.completed` + * markiert den Kauf über ProcessStripeWebhook als bezahlt. + */ + public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout + { + $this->syncTaxIdFromBillingAddress($user); + + return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ + ...$this->sessionOptions(), + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ]); + } +} diff --git a/app/Services/Billing/StripePlanSyncService.php b/app/Services/Billing/StripePlanSyncService.php new file mode 100644 index 0000000..253b6e1 --- /dev/null +++ b/app/Services/Billing/StripePlanSyncService.php @@ -0,0 +1,121 @@ + $changes + */ + public function syncAfterUpdate(Plan $plan, array $changes): void + { + if (! $this->isConfigured()) { + return; + } + + $stripe = Cashier::stripe(); + + if (! $plan->stripe_product_id) { + $this->createProductWithPrices($stripe, $plan); + + return; + } + + if (array_key_exists('name', $changes)) { + $stripe->products->update($plan->stripe_product_id, ['name' => $plan->name]); + } + + if (array_key_exists('monthly_price_cents', $changes)) { + $plan->forceFill([ + 'stripe_price_id_monthly' => $this->rotatePrice( + $stripe, + $plan, + $plan->stripe_price_id_monthly, + $plan->monthly_price_cents, + 'month', + ), + ])->save(); + } + + if (array_key_exists('yearly_price_cents', $changes)) { + $plan->forceFill([ + 'stripe_price_id_yearly' => $this->rotatePrice( + $stripe, + $plan, + $plan->stripe_price_id_yearly, + $plan->yearly_price_cents, + 'year', + ), + ])->save(); + } + } + + /** + * Erstanlage für Tarife ohne Stripe-Verknüpfung — gleiche Struktur wie + * `billing:sync-stripe-plans`, nur direkt aus der Admin-Oberfläche. + */ + private function createProductWithPrices(StripeClient $stripe, Plan $plan): void + { + $product = $stripe->products->create([ + 'name' => $plan->name, + 'metadata' => ['plan_slug' => $plan->slug], + ]); + + $plan->forceFill([ + 'stripe_product_id' => $product->id, + 'stripe_price_id_monthly' => $this->createPrice($stripe, $product->id, $plan, $plan->monthly_price_cents, 'month'), + 'stripe_price_id_yearly' => $this->createPrice($stripe, $product->id, $plan, $plan->yearly_price_cents, 'year'), + ])->save(); + } + + /** + * Neuen Preis anlegen und den bisherigen (falls vorhanden) für neue + * Buchungen deaktivieren. Gibt die neue Price-ID zurück. + */ + private function rotatePrice(StripeClient $stripe, Plan $plan, ?string $oldPriceId, int $unitAmount, string $interval): string + { + $newPriceId = $this->createPrice($stripe, (string) $plan->stripe_product_id, $plan, $unitAmount, $interval); + + if ($oldPriceId) { + $stripe->prices->update($oldPriceId, ['active' => false]); + } + + return $newPriceId; + } + + private function createPrice(StripeClient $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string + { + $price = $stripe->prices->create([ + 'product' => $productId, + 'currency' => strtolower($plan->currency), + 'unit_amount' => $unitAmount, + 'tax_behavior' => 'exclusive', + 'recurring' => ['interval' => $interval], + 'metadata' => ['plan_slug' => $plan->slug], + ]); + + return $price->id; + } +} diff --git a/app/Services/Billing/VatIdValidationService.php b/app/Services/Billing/VatIdValidationService.php new file mode 100644 index 0000000..2ff293d --- /dev/null +++ b/app/Services/Billing/VatIdValidationService.php @@ -0,0 +1,148 @@ +result(VatIdCheckStatus::Unverified, __('Keine USt-ID angegeben.')); + } + + $prefix = substr($vatId, 0, 2); + $prefixCountry = $prefix === 'EL' ? 'GR' : $prefix; + + if (! $this->vatResolver->isPlausibleVatId($vatId, $prefixCountry)) { + return $this->result( + VatIdCheckStatus::FormatInvalid, + __('Das Format der USt-ID ist ungültig (erwartet: Ländercode + Kennziffer, z. B. DE123456789).'), + ); + } + + $billingCountryCode = strtoupper((string) $billingCountryCode); + + if ($billingCountryCode !== '' && ! $this->vatResolver->isPlausibleVatId($vatId, $billingCountryCode)) { + return $this->result( + VatIdCheckStatus::FormatInvalid, + __('Die USt-ID passt nicht zum Land der Rechnungsadresse (:country).', ['country' => $billingCountryCode]), + ); + } + + if ($prefix === 'DE') { + return $this->result( + VatIdCheckStatus::Unverified, + __('Format plausibel. Deutsche USt-IDs können über eVatR nicht online bestätigt werden.'), + ); + } + + if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) { + return $this->result( + VatIdCheckStatus::Unverified, + __('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'), + ); + } + + $ownVatId = (string) config('billing.own_vat_id'); + + if ($ownVatId === '') { + return $this->result( + VatIdCheckStatus::Unverified, + __('Format plausibel. Online-Prüfung ist nicht konfiguriert (BILLING_OWN_VAT_ID).'), + ); + } + + return $this->confirmViaEvatr($vatId, $ownVatId); + } + + /** + * @return array{status: VatIdCheckStatus, message: string} + */ + private function confirmViaEvatr(string $vatId, string $ownVatId): array + { + $cacheKey = 'evatr-check:'.$vatId; + + /** @var array{status: VatIdCheckStatus, message: string}|null $cached */ + $cached = Cache::get($cacheKey); + + if ($cached !== null) { + return $cached; + } + + try { + $response = Http::timeout(8) + ->acceptJson() + ->post(self::EVATR_ENDPOINT, [ + 'anfragendeUstid' => $ownVatId, + 'angefragteUstid' => $vatId, + ]); + } catch (\Throwable $exception) { + Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]); + + return $this->result( + VatIdCheckStatus::Unverified, + __('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht erreichbar.'), + ); + } + + if (! $response->successful()) { + return $this->result( + VatIdCheckStatus::Unverified, + __('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht verfügbar.'), + ); + } + + $status = (string) $response->json('status'); + + $result = $status === self::STATUS_VALID + ? $this->result(VatIdCheckStatus::Valid, __('USt-ID per eVatR (BZSt) bestätigt — gültig.')) + : $this->result( + VatIdCheckStatus::Invalid, + __('Die USt-ID wurde von eVatR (BZSt) nicht bestätigt (Status :status).', ['status' => $status ?: 'unbekannt']), + ); + + // Nur definitive Ergebnisse cachen — Ausfälle der API sollen beim + // nächsten Versuch erneut geprüft werden. + Cache::put($cacheKey, $result, now()->addHours(6)); + + return $result; + } + + /** + * @return array{status: VatIdCheckStatus, message: string} + */ + private function result(VatIdCheckStatus $status, string $message): array + { + return ['status' => $status, 'message' => $message]; + } +} diff --git a/app/Services/Billing/VatResolver.php b/app/Services/Billing/VatResolver.php new file mode 100644 index 0000000..197c471 --- /dev/null +++ b/app/Services/Billing/VatResolver.php @@ -0,0 +1,67 @@ +isPlausibleVatId($vatId, $countryCode) + ? VatTreatment::ReverseCharge + : VatTreatment::EuConsumer; + } + + public function rateFor(VatTreatment $treatment): float + { + return $treatment->isTaxExempt() ? 0.0 : (float) config('billing.vat_rate', 0.19); + } + + public function taxCentsFor(int $netCents, VatTreatment $treatment): int + { + return (int) round($netCents * $this->rateFor($treatment)); + } + + /** + * Formale Plausibilität: beginnt mit dem Ländercode der Adresse und + * trägt danach 2–13 alphanumerische Zeichen (EU-Formatrahmen). + * Public, damit der VatIdValidationService dieselbe Definition nutzt. + */ + public function isPlausibleVatId(?string $vatId, string $countryCode): bool + { + $vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? ''); + + if ($vatId === '') { + return false; + } + + // Griechenland nutzt das Präfix EL statt GR. + $expectedPrefix = $countryCode === 'GR' ? 'EL' : $countryCode; + + return (bool) preg_match('/^'.preg_quote($expectedPrefix, '/').'[A-Z0-9]{2,13}$/', $vatId); + } +} diff --git a/app/Services/Customer/CustomerCompanyContext.php b/app/Services/Customer/CustomerCompanyContext.php index 1553828..a474aec 100644 --- a/app/Services/Customer/CustomerCompanyContext.php +++ b/app/Services/Customer/CustomerCompanyContext.php @@ -2,6 +2,7 @@ namespace App\Services\Customer; +use App\Enums\Portal; use App\Models\Company; use App\Models\User; use Illuminate\Database\Eloquent\Builder; @@ -78,6 +79,30 @@ class CustomerCompanyContext ->get(); } + /** + * @return Collection + */ + public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection + { + $term = Portal::stripTrailingAbbreviation($term); + $limit = max(1, $limit); + + if ($term === '') { + return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit); + } + + return $this->companyOptionQuery($user) + ->where(function (Builder $query) use ($term): void { + $query->where('companies.name', 'like', '%'.$term.'%') + ->orWhere('companies.slug', 'like', '%'.$term.'%') + ->orWhere('companies.email', 'like', '%'.$term.'%'); + }) + ->orderBy('companies.name') + ->orderBy('companies.id') + ->limit($limit) + ->get(); + } + public function companyCountFor(User $user): int { return $this->accessibleCompanyQuery($user)->count(); @@ -181,6 +206,41 @@ class CustomerCompanyContext ]); } + /** + * @return Builder + */ + private function companyOptionQuery(User $user): Builder + { + return $this->accessibleCompanyQuery($user) + ->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']); + } + + /** + * @return Collection + */ + private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection + { + $selectedCompany = $selectedCompanyId + ? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first() + : null; + + $companies = $this->companyOptionQuery($user) + ->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id)) + ->latest('companies.created_at') + ->latest('companies.id') + ->limit($selectedCompany ? max(0, $limit - 1) : $limit) + ->get(); + + if (! $selectedCompany) { + return $companies; + } + + return $companies + ->prepend($selectedCompany) + ->unique('id') + ->values(); + } + private function userCanAccessCompany(User $user, int $companyId): bool { return $this->accessibleCompanyQuery($user) diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index 35a6804..8006737 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -43,6 +43,8 @@ class ImageService 'thumb' => ['width' => 320, 'height' => 240], 'medium' => ['width' => 800, 'height' => 600], 'large' => ['width' => 1600, 'height' => 1200], + // Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px. + 'cover' => ['width' => 1280, 'height' => 580], ]; public const ALLOWED_LOGO_MIME_TYPES = [ @@ -60,7 +62,7 @@ class ImageService public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB - public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB + public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB public function __construct(private readonly string $disk = 'public') {} @@ -99,8 +101,9 @@ class ImageService } /** - * Persists a freshly uploaded press release image and generates all - * variants. Original is stored under `press-releases/{id}/images`. + * Persists a freshly uploaded press release image, generates all variants + * and discards the original upload. The canonical stored path points to + * the cover variant to keep storage usage predictable. * * @return array{ * path: string, @@ -122,9 +125,6 @@ class ImageService $disk = $this->disk(); $disk->put($relativePath, $upload->get(), 'public'); - $absolute = $disk->path($relativePath); - $size = @getimagesize($absolute) ?: [null, null]; - $variants = $this->generateVariants( $disk, $relativePath, @@ -133,11 +133,19 @@ class ImageService cover: true, ); + $coverPath = $variants['cover'] ?? $relativePath; + $coverAbsolute = $disk->path($coverPath); + $coverSize = @getimagesize($coverAbsolute) ?: [null, null]; + + if ($coverPath !== $relativePath && $disk->exists($relativePath)) { + $disk->delete($relativePath); + } + return [ - 'path' => $relativePath, + 'path' => $coverPath, 'variants' => $variants, - 'width' => is_int($size[0] ?? null) ? $size[0] : null, - 'height' => is_int($size[1] ?? null) ? $size[1] : null, + 'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null, + 'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null, 'mime' => $upload->getMimeType(), ]; } diff --git a/app/Services/PressRelease/BookingRequiredException.php b/app/Services/PressRelease/BookingRequiredException.php new file mode 100644 index 0000000..ddf097f --- /dev/null +++ b/app/Services/PressRelease/BookingRequiredException.php @@ -0,0 +1,17 @@ +config->get('scoring.classification.provider') ?: 'deterministic'); + } + + public function createDeterministicDriver(): ClassificationDriver + { + return $this->container->make(DeterministicClassificationDriver::class); + } + + public function createOpenaiDriver(): ClassificationDriver + { + return $this->container->make(OpenAiClassificationDriver::class); + } +} diff --git a/app/Services/PressRelease/Classification/ClassificationResult.php b/app/Services/PressRelease/Classification/ClassificationResult.php new file mode 100644 index 0000000..9a0ea91 --- /dev/null +++ b/app/Services/PressRelease/Classification/ClassificationResult.php @@ -0,0 +1,31 @@ + $reasons Begründungen der KI (kann leer sein) + * @param array $rawResponse Roh-Antwort für das Audit-Log + */ + public function __construct( + public readonly PressReleaseClassification $classification, + public readonly array $reasons, + public readonly string $provider, + public readonly ?string $model, + public readonly array $rawResponse, + ) {} + + public function reasonText(): ?string + { + return $this->reasons === [] ? null : implode('; ', $this->reasons); + } +} diff --git a/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php b/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php new file mode 100644 index 0000000..16f4a87 --- /dev/null +++ b/app/Services/PressRelease/Classification/Contracts/ClassificationDriver.php @@ -0,0 +1,21 @@ +blacklist->findInPressRelease($pressRelease); + + if ($word !== null) { + return new ClassificationResult( + classification: PressReleaseClassification::Red, + reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)], + provider: 'deterministic', + model: null, + rawResponse: ['matched_word' => $word], + ); + } + + return new ClassificationResult( + classification: PressReleaseClassification::Green, + reasons: [], + provider: 'deterministic', + model: null, + rawResponse: ['matched_word' => null], + ); + } +} diff --git a/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php new file mode 100644 index 0000000..fb7967f --- /dev/null +++ b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php @@ -0,0 +1,115 @@ +timeout($timeout) + ->acceptJson() + ->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $this->systemPrompt()], + ['role' => 'user', 'content' => $this->userPrompt($pressRelease)], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()})."); + } + + $payload = $response->json(); + $content = data_get($payload, 'choices.0.message.content'); + + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.'); + } + + $parsed = json_decode($content, true); + + if (! is_array($parsed) || ! isset($parsed['classification'])) { + throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.'); + } + + $classification = PressReleaseClassification::tryFrom((string) $parsed['classification']); + + if ($classification === null) { + throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.'); + } + + $reasons = array_values(array_filter(array_map( + static fn ($reason): string => (string) $reason, + is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [], + ))); + + return new ClassificationResult( + classification: $classification, + reasons: $reasons, + provider: 'openai', + model: $model, + rawResponse: is_array($payload) ? $payload : [], + ); + } + + private function systemPrompt(): string + { + return <<<'PROMPT' + Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal. + Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf. + + Klassifiziere genau eine der drei Stufen: + - "green": unauffällig, kann veröffentlicht werden. + - "yellow": grenzwertig/unklar, sollte manuell geprüft werden. + - "red": unzulässig, darf nicht veröffentlicht werden. + + Prüfe insbesondere diese Faktoren (Red Flags): + - reine Werbung statt journalistischer Pressemitteilung + - beleidigende, diskriminierende oder hetzerische Inhalte + - rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen, + Verleumdung, Aufruf zu Straftaten) + - Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links) + - unseriöse oder offensichtlich falsche Versprechen + + Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema: + {"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]} + Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch. + PROMPT; + } + + private function userPrompt(PressRelease $pressRelease): string + { + $title = (string) $pressRelease->title; + $text = trim(strip_tags((string) $pressRelease->text)); + + return "Titel:\n{$title}\n\nText:\n{$text}"; + } +} diff --git a/app/Services/PressRelease/ContentScore/ContentScoreManager.php b/app/Services/PressRelease/ContentScore/ContentScoreManager.php new file mode 100644 index 0000000..2fdd753 --- /dev/null +++ b/app/Services/PressRelease/ContentScore/ContentScoreManager.php @@ -0,0 +1,30 @@ +config->get('scoring.content_score.provider') ?: 'deterministic'); + } + + public function createDeterministicDriver(): ContentScoreDriver + { + return $this->container->make(DeterministicContentScoreDriver::class); + } + + public function createOpenaiDriver(): ContentScoreDriver + { + return $this->container->make(OpenAiContentScoreDriver::class); + } +} diff --git a/app/Services/PressRelease/ContentScore/ContentScoreResult.php b/app/Services/PressRelease/ContentScore/ContentScoreResult.php new file mode 100644 index 0000000..404e5ae --- /dev/null +++ b/app/Services/PressRelease/ContentScore/ContentScoreResult.php @@ -0,0 +1,25 @@ + $breakdown Faktor-Aufschlüsselung (optional) + * @param array $rawResponse Roh-Antwort für das Audit-Log + */ + public function __construct( + public readonly int $score, + public readonly array $breakdown, + public readonly string $provider, + public readonly ?string $model, + public readonly array $rawResponse, + ) {} +} diff --git a/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php b/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php new file mode 100644 index 0000000..844ce50 --- /dev/null +++ b/app/Services/PressRelease/ContentScore/Contracts/ContentScoreDriver.php @@ -0,0 +1,21 @@ +plainTextLength(); + $lengthPoints = match (true) { + $textLength >= 1500 => 20, + $textLength >= 800 => 12, + $textLength >= 300 => 6, + default => 0, + }; + $score += $lengthPoints; + $breakdown['length'] = $lengthPoints; + + $subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0; + $score += $subtitlePoints; + $breakdown['subtitle'] = $subtitlePoints; + + $imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0; + $score += $imagePoints; + $breakdown['image'] = $imagePoints; + + $sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0; + $score += $sourcePoints; + $breakdown['source'] = $sourcePoints; + + $titleLength = mb_strlen((string) $pressRelease->title); + $headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0; + $score += $headlinePoints; + $breakdown['headline'] = $headlinePoints; + + $keywordPoints = filled($pressRelease->keywords) ? 4 : 0; + $score += $keywordPoints; + $breakdown['keywords'] = $keywordPoints; + + $contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0; + $score += $contactPoints; + $breakdown['contact'] = $contactPoints; + + $score = max(0, min(100, $score)); + + return new ContentScoreResult( + score: $score, + breakdown: $breakdown, + provider: 'deterministic', + model: null, + rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength], + ); + } +} diff --git a/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php new file mode 100644 index 0000000..2f38c5f --- /dev/null +++ b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php @@ -0,0 +1,107 @@ +timeout($timeout) + ->acceptJson() + ->post($openai['url'] ?? 'https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $this->systemPrompt()], + ['role' => 'user', 'content' => $this->userPrompt($pressRelease)], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException("OpenAI-Content-Score fehlgeschlagen (HTTP {$response->status()})."); + } + + $payload = $response->json(); + $content = data_get($payload, 'choices.0.message.content'); + + if (! is_string($content) || trim($content) === '') { + throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.'); + } + + $parsed = json_decode($content, true); + + if (! is_array($parsed) || ! isset($parsed['score']) || ! is_numeric($parsed['score'])) { + throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.'); + } + + $score = (int) max(0, min(100, (int) round((float) $parsed['score']))); + $breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : []; + + return new ContentScoreResult( + score: $score, + breakdown: $breakdown, + provider: 'openai', + model: $model, + rawResponse: is_array($payload) ? $payload : [], + ); + } + + private function systemPrompt(): string + { + return <<<'PROMPT' + Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung + auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die + Qualität, nicht die Zulässigkeit. + + Berücksichtige diese gewichteten Kategorien: + - Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate + - Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau + - Lesbarkeit (10%): Satzlängen, angemessene Fachsprache + - Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region + - Bildmaterial (10%): Bild vorhanden/erwähnt + - Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen + - Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz + - Originalität (10%): kein Boilerplate, individueller Ton + + Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema: + {"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15, + "lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10, + "quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}} + PROMPT; + } + + private function userPrompt(PressRelease $pressRelease): string + { + $title = (string) $pressRelease->title; + $text = trim(strip_tags((string) $pressRelease->text)); + $hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein'; + $source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—'; + + return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}"; + } +} diff --git a/app/Services/PressRelease/PressReleaseCoverImage.php b/app/Services/PressRelease/PressReleaseCoverImage.php new file mode 100644 index 0000000..6f7ed51 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseCoverImage.php @@ -0,0 +1,92 @@ +coverImage($pressRelease); + + if (! $image) { + return $this->placeholderUrl($pressRelease); + } + + foreach ($this->fallbackChain($variant) as $key) { + $url = $image->variantUrl($key); + + if ($url !== null) { + return $url; + } + } + + return $image->url() ?? $this->placeholderUrl($pressRelease); + } + + /** + * Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten. + * + * @return list + */ + private function fallbackChain(string $preferred): array + { + $chain = [$preferred, 'cover', 'large', 'medium', 'thumb']; + + return array_values(array_unique($chain)); + } + + /** + * Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt. + */ + public function coverIsPlaceholder(PressRelease $pressRelease): bool + { + return $this->coverImage($pressRelease) === null; + } + + /** + * Die Platzhalter-Variante dieser PM (mit Default-Fallback). + */ + public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder + { + $variant = $pressRelease->placeholder_variant; + + if ($variant instanceof PressReleasePlaceholder) { + return $variant; + } + + return PressReleasePlaceholder::fromValueOrDefault($variant); + } + + private function placeholderUrl(PressRelease $pressRelease): string + { + return asset($this->placeholder($pressRelease)->path()); + } + + private function coverImage(PressRelease $pressRelease): ?PressReleaseImage + { + $images = $pressRelease->relationLoaded('images') + ? $pressRelease->images + : $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get(); + + return $images->firstWhere('is_preview', true) ?? $images->first(); + } +} diff --git a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php index 412c3cc..80ed517 100644 --- a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php +++ b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php @@ -17,6 +17,8 @@ class PressReleaseHtmlSanitizer { private const string PURIFIER_PROFILE = 'press_release'; + public function __construct(private readonly PressReleaseLinkPolicy $linkPolicy) {} + /** * Sanitize HTML before persisting to the database. */ @@ -44,6 +46,10 @@ class PressReleaseHtmlSanitizer /** * Produce a display-ready, safe HtmlString. + * + * Die Link-Policy (rel systemseitig: extern sponsored/nofollow, + * portalintern follow) greift hier beim Rendern — so wirken + * Regel-Änderungen rückwirkend auf alle gespeicherten Inhalte. */ public function render(?string $text): HtmlString { @@ -52,7 +58,7 @@ class PressReleaseHtmlSanitizer } if ($this->isHtml($text)) { - return new HtmlString($this->clean($text)); + return new HtmlString($this->linkPolicy->apply($this->clean($text))); } $escaped = e($text); diff --git a/app/Services/PressRelease/PressReleaseLinkPolicy.php b/app/Services/PressRelease/PressReleaseLinkPolicy.php new file mode 100644 index 0000000..7cbc812 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseLinkPolicy.php @@ -0,0 +1,132 @@ +loadHTML( + '

', + LIBXML_NOERROR | LIBXML_NOWARNING, + ); + + if (! $loaded) { + return $html; + } + + foreach ($document->getElementsByTagName('a') as $anchor) { + $this->applyToAnchor($anchor); + } + + $root = $document->getElementById('pr-link-policy-root'); + + if (! $root) { + return $html; + } + + $result = ''; + + foreach ($root->childNodes as $child) { + $result .= $document->saveHTML($child); + } + + return $result; + } + + private function applyToAnchor(DOMElement $anchor): void + { + $href = trim($anchor->getAttribute('href')); + + // rel ist systemgesteuert — Autoren-Eingaben zählen nie. + $anchor->removeAttribute('rel'); + + if ($href === '' || Str::startsWith($href, ['mailto:', 'tel:', '#'])) { + $anchor->removeAttribute('target'); + + return; + } + + if ($this->isInternal($href)) { + $anchor->removeAttribute('target'); + + return; + } + + $anchor->setAttribute('rel', 'sponsored nofollow noopener'); + $anchor->setAttribute('target', '_blank'); + } + + /** + * Intern = relative Pfade sowie absolute URLs auf eine der + * konfigurierten Portal-Domains (inkl. www-Variante). + */ + private function isInternal(string $href): bool + { + if (! preg_match('#^https?://#i', $href)) { + // Relative Pfade (/firma/...) — kein Protokoll, keine Domain. + return ! Str::startsWith($href, '//'); + } + + $host = strtolower((string) parse_url($href, PHP_URL_HOST)); + + if ($host === '') { + return false; + } + + $internalHosts = $this->internalHosts(); + + return in_array($host, $internalHosts, true) + || in_array(Str::after($host, 'www.'), $internalHosts, true); + } + + /** + * @return list + */ + private function internalHosts(): array + { + $hosts = []; + + foreach ((array) config('domains.domains', []) as $domain) { + foreach ([$domain['domain_name'] ?? null, parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST)] as $candidate) { + if (is_string($candidate) && $candidate !== '') { + $host = strtolower($candidate); + $hosts[] = $host; + $hosts[] = Str::after($host, 'www.'); + } + } + } + + return array_values(array_unique($hosts)); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index b8eca22..87be64a 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -2,7 +2,11 @@ namespace App\Services\PressRelease; +use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; +use App\Enums\SinglePurchaseStatus; +use App\Jobs\ClassifyPressRelease; +use App\Jobs\ScorePressRelease; use App\Mail\PressReleasePublished; use App\Mail\PressReleaseRejected; use App\Models\AdminPreset; @@ -29,6 +33,25 @@ class PressReleaseService { $this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]); + $user = $pressRelease->user; + + // Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive + // Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub. + if ($user && ! $user->hasActiveBooking()) { + throw new BookingRequiredException; + } + + // Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht + // (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn + // noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne + // verfügbares Kontingent automatisch veröffentlicht. Null bedeutet + // unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf). + $quotaRemaining = $user?->pressReleaseQuotaRemaining(); + + if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) { + throw new QuotaExceededException; + } + $previous = $pressRelease->status; if ($word = $this->blacklist->findInPressRelease($pressRelease)) { @@ -43,9 +66,96 @@ class PressReleaseService $pressRelease->update(['status' => PressReleaseStatus::Review->value]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer'); + + // KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand + // des Ergebnisses übernimmt der Job über routeByClassification(). + ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification'); + + // Content-Score parallel berechnen (Qualität, ohne Statuswirkung). + ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification'); } - public function publish(PressRelease $pressRelease, string $source = 'admin'): void + /** + * Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal + * klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert"). + * + * Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden + * aktualisiert, der Status bleibt unverändert. So führt das bloße + * Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung + * oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin. + */ + public function reclassifyIfClassified(PressRelease $pressRelease): void + { + if ($pressRelease->classification === null) { + return; + } + + ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification'); + } + + /** + * Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits + * einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet"). + */ + public function rescoreIfScored(PressRelease $pressRelease): void + { + if ($pressRelease->content_score === null) { + return; + } + + ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification'); + } + + /** + * Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses + * (Decision-Update §5.0, Entscheidung 12.06.2026). Wird vom + * ClassifyPressRelease-Job aufgerufen. + * + * - Rot → Ablehnung mit Begründung an den Autor + * - Gelb/Grün → automatische Veröffentlichung (sofort bzw. zum Termin); + * Gelb bleibt als interne Markierung erhalten (nicht + * boostbar, Admin-Signal), löst aber keine manuelle + * Prüfung aus + * + * Greift nur, solange die PM noch im Status `review` steht; manuelle + * Admin-Eingriffe in der Zwischenzeit haben damit Vorrang. + */ + public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void + { + if ($pressRelease->status !== PressReleaseStatus::Review) { + return; + } + + if ($classification === PressReleaseClassification::Red) { + $this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki'); + + return; + } + + $this->autoPublishApproved($pressRelease); + } + + /** + * Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch. + * + * Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der + * Scheduler die Publikation zum Termin. Andernfalls wird sofort + * publiziert – optional mit einem Sicherheitsfenster + * (scoring.classification.green_delay_minutes). + */ + private function autoPublishApproved(PressRelease $pressRelease): void + { + if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) { + return; + } + + $delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0); + $publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null; + + $this->publish($pressRelease, 'ki', $publishedAtOverride); + } + + public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Review]); @@ -61,15 +171,65 @@ class PressReleaseService throw new BlacklistViolationException($reason, $word); } + $this->consumePublishSlot($pressRelease); + $pressRelease->update([ 'status' => PressReleaseStatus::Published->value, - 'published_at' => $this->resolvePublishedAt($pressRelease), + 'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride), ]); $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source); $this->notifyAuthor($pressRelease, 'published'); } + /** + * Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers + * (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte + * PMs kosten nichts). Erneutes Publizieren — etwa nach Archivierung — + * zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem + * Schreiben des neuen Status-Logs aufgerufen werden. + * + * Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler + * `press_release_quota_used_this_month`), danach der älteste bezahlte + * Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst). + * Unbegrenzte User (Bestandsschutz) verbrauchen nichts. + */ + private function consumePublishSlot(PressRelease $pressRelease): void + { + $alreadyPublishedOnce = PressReleaseStatusLog::query() + ->where('press_release_id', $pressRelease->id) + ->where('to_status', PressReleaseStatus::Published->value) + ->exists(); + + if ($alreadyPublishedOnce) { + return; + } + + $user = $pressRelease->user; + + if (! $user || $user->hasUnlimitedPressReleaseQuota()) { + return; + } + + $plan = $user->currentPlan(); + + if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) { + $user->increment('press_release_quota_used_this_month'); + + return; + } + + $user->singlePurchases() + ->grantingSubmission() + ->oldest('paid_at') + ->first() + ?->update([ + 'status' => SinglePurchaseStatus::Consumed->value, + 'consumed_at' => now(), + 'press_release_id' => $pressRelease->id, + ]); + } + /** * Bestimmt das wirksame `published_at` einer PM. * @@ -83,14 +243,18 @@ class PressReleaseService * Damit wirken sowohl Scheduling als auch Embargo automatisch über den * vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im * öffentlichen Listing. + * + * `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das + * Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt + * ist – ein geplanter Termin hat stets Vorrang. */ - private function resolvePublishedAt(PressRelease $pressRelease): Carbon + private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon { if ($pressRelease->published_at) { return $pressRelease->published_at; } - $base = $pressRelease->scheduled_at ?: now(); + $base = $pressRelease->scheduled_at ?: ($override ?? now()); if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) { return $pressRelease->embargo_at; @@ -99,7 +263,7 @@ class PressReleaseService return $base; } - public function reject(PressRelease $pressRelease, ?string $reason = null): void + public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void { $this->assertStatus($pressRelease, [PressReleaseStatus::Review]); @@ -107,7 +271,7 @@ class PressReleaseService $pressRelease->update(['status' => PressReleaseStatus::Rejected->value]); - $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin'); + $this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source); $this->notifyAuthor($pressRelease, 'rejected', $reason); } @@ -135,6 +299,10 @@ class PressReleaseService { $previous = $pressRelease->status; + if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) { + $this->consumePublishSlot($pressRelease); + } + $pressRelease->update([ 'status' => $status->value, 'published_at' => $status === PressReleaseStatus::Published diff --git a/app/Services/PressRelease/QuotaExceededException.php b/app/Services/PressRelease/QuotaExceededException.php new file mode 100644 index 0000000..bdf63bc --- /dev/null +++ b/app/Services/PressRelease/QuotaExceededException.php @@ -0,0 +1,17 @@ + $defaults + * @param array> $configuredDomains + * @return array + */ + public static function resolve( + string $host, + array $defaults, + array $configuredDomains, + ?string $themeOverride = null, + ?Request $request = null, + ): array { + if ($themeOverride !== null && isset($configuredDomains[$themeOverride])) { + return array_merge($defaults, $configuredDomains[$themeOverride]); + } + + $portalHost = (string) config('domains.domain_portal'); + + if ($host === $portalHost) { + $request ??= request(); + + if (self::isBackendRequest($request)) { + return array_merge($defaults, $configuredDomains['portal'] ?? $defaults); + } + + return array_merge($defaults, $configuredDomains['pressekonto'] ?? $defaults); + } + + foreach ($configuredDomains as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['domain_name'] ?? null) === $host) { + return array_merge($defaults, $config); + } + } + + return $defaults; + } + + public static function isBackendRequest(Request $request): bool + { + if ($request->routeIs( + 'dashboard', + 'login', + 'register', + 'password.*', + 'verification.*', + 'two-factor.*', + 'magic-links.*', + 'me.*', + 'settings.*', + 'press-releases.preview', + )) { + return true; + } + + $backendPrefixes = [ + 'admin', + 'customer', + 'dashboard', + 'login', + 'register', + 'forgot-password', + 'reset-password', + 'magic-login', + 'user', + 'settings', + 'flux', + 'livewire', + ]; + + $path = trim($request->path(), '/'); + + foreach ($backendPrefixes as $prefix) { + if ($path === $prefix || str_starts_with($path, "{$prefix}/")) { + return true; + } + } + + return false; + } + + public static function usesWebAssets(string $assetsDir): bool + { + return $assetsDir === 'build/web'; + } + + public static function hotFilePath(string $assetsDir): string + { + return self::usesWebAssets($assetsDir) + ? public_path('hot-web') + : public_path('hot-portal'); + } + + /** + * @param array $domainConfig + */ + public static function configureVite(array $domainConfig): void + { + $assetsDir = (string) ($domainConfig['assets_dir'] ?? 'build/portal'); + + Vite::useBuildDirectory($assetsDir); + Vite::useHotFile(self::hotFilePath($assetsDir)); + } + + /** + * @param array $domainConfig + */ + public static function staticAssetOrigin(array $domainConfig): string + { + return (string) ($domainConfig['url'] ?? config('app.url')); + } + + /** + * @param array $domainConfig + */ + public static function viteDevServerUrl(array $domainConfig): string + { + if (isset($domainConfig['asset_url'])) { + return (string) $domainConfig['asset_url']; + } + + $assetsDir = (string) ($domainConfig['assets_dir'] ?? 'build/portal'); + + return self::usesWebAssets($assetsDir) + ? 'https://assets.pressekonto.test' + : 'https://assets.pressekonto.test'; + } + + public static function isViteDevServerRunning(string $assetsDir): bool + { + return is_file(self::hotFilePath($assetsDir)); + } +} diff --git a/composer.json b/composer.json index 89b7807..3954ef5 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "blade-ui-kit/blade-heroicons": "^2.6", + "laravel/cashier": "^16.5", "laravel/fortify": "^1.27", "laravel/framework": "^12.0", "laravel/sanctum": "^4.1", diff --git a/composer.lock b/composer.lock index 6db2f41..4c4be24 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cbc29fc1cf64ca319c7c0ef7e0c1088c", + "content-hash": "7ad3d072c1669ef5d37e58ba10187b58", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1369,6 +1369,95 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "laravel/cashier", + "version": "v16.5.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^17.3.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.0|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.22.1", + "symfony/polyfill-php84": "^1.32" + }, + "require-dev": { + "dompdf/dompdf": "^2.0|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "spatie/laravel-ray": "^1.40" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", + "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2026-05-05T21:18:35+00:00" + }, { "name": "laravel/fortify", "version": "v1.36.2", @@ -2826,6 +2915,96 @@ }, "time": "2026-04-15T16:41:08+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" + }, + "time": "2026-05-04T20:23:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4306,6 +4485,65 @@ ], "time": "2026-03-17T22:46:46+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v17.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v17.6.0" + }, + "time": "2025-08-27T19:32:42+00:00" + }, { "name": "symfony/clock", "version": "v8.0.8", @@ -5467,6 +5705,94 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:53+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.36.0", diff --git a/config/auth.php b/config/auth.php index 0ba5d5d..9daae00 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..793adfa --- /dev/null +++ b/config/billing.php @@ -0,0 +1,79 @@ + env('BILLING_ENFORCE_BOOKING', false), + + /* + |-------------------------------------------------------------------------- + | Hybride Rechnungskreise + |-------------------------------------------------------------------------- + | + | Alle neuen Abschlüsse laufen über Stripe und erhalten fortlaufende + | Nummern im STR-Kreis. Laufende Legacy-Zahlungen werden ab Relaunch im + | eigenen MAN-Kreis weiter per Rechnung abgerechnet (Fälligkeitsprüfung + | via `billing:generate-manual-invoices`). Die Alt-Rechnungen aus den + | Ursprungsportalen bleiben unverändert in `legacy_invoices`. + | + */ + + 'invoice_number_padding' => 5, + + // Zahlungsziel für Rechnungen des manuellen Kreises (Tage). + 'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14), + + /* + |-------------------------------------------------------------------------- + | Einzel-Pressemitteilung (Pay-per-Release) + |-------------------------------------------------------------------------- + | + | Netto-Preis laut Decision-Update. Die Stripe-Price-ID wird einmalig + | von `billing:sync-stripe-plans` angelegt und hier per ENV verdrahtet — + | ohne sie ist der Einzel-PM-Checkout deaktiviert. + | + */ + + 'single_pm_price_cents' => 1900, + + 'single_pm_stripe_price_id' => env('STRIPE_PRICE_SINGLE_PM'), + + /* + |-------------------------------------------------------------------------- + | USt-Behandlung (Entscheidung 12.06.2026) + |-------------------------------------------------------------------------- + | + | Alle neuen Preise sind NETTO. Die Steuer wird zur Rechnungsstellung + | anhand der Rechnungsadresse bestimmt (VatResolver): Deutschland immer + | mit Steuer, EU-Ausland nur mit gültiger USt-ID befreit (Reverse + | Charge), Drittländer grundsätzlich befreit. + | + */ + + 'vat_rate' => env('BILLING_VAT_RATE', 0.19), + + // Eigene deutsche USt-ID des Betreibers — Pflichtangabe für die + // eVatR-Online-Bestätigung ausländischer EU-USt-IDs (BZSt-REST-API). + // Ohne sie bleibt die USt-ID-Prüfung eine reine Formatprüfung. + 'own_vat_id' => env('BILLING_OWN_VAT_ID'), + + // EU-Mitgliedstaaten (ISO 3166-1 alpha-2), Stand 2026 — ohne DE, + // das im VatResolver als Inland behandelt wird. + 'eu_country_codes' => [ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', + 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', + 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', + ], + +]; diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 0000000..574e2ca --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,130 @@ + env('STRIPE_KEY'), + + 'secret' => env('STRIPE_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Cashier Path + |-------------------------------------------------------------------------- + | + | This is the base URI path where Cashier's views, such as the payment + | verification screen, will be available from. You're free to tweak + | this path according to your preferences and application design. + | + */ + + 'path' => env('CASHIER_PATH', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Stripe Webhooks + |-------------------------------------------------------------------------- + | + | Your Stripe webhook secret is used to prevent unauthorized requests to + | your Stripe webhook handling controllers. The tolerance setting will + | check the drift between the current time and the signed request's. + | + */ + + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), + 'events' => WebhookCommand::DEFAULT_EVENTS, + ], + + /* + |-------------------------------------------------------------------------- + | Currency + |-------------------------------------------------------------------------- + | + | This is the default currency that will be used when generating charges + | from your application. Of course, you are welcome to use any of the + | various world currencies that are currently supported via Stripe. + | + */ + + 'currency' => env('CASHIER_CURRENCY', 'usd'), + + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'), + + /* + |-------------------------------------------------------------------------- + | Payment Confirmation Notification + |-------------------------------------------------------------------------- + | + | If this setting is enabled, Cashier will automatically notify customers + | whose payments require additional verification. You should listen to + | Stripe's webhooks in order for this feature to function correctly. + | + */ + + 'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'), + + /* + |-------------------------------------------------------------------------- + | Invoice Settings + |-------------------------------------------------------------------------- + | + | The following options determine how Cashier invoices are converted from + | HTML into PDFs. You're free to change the options based on the needs + | of your application or your preferences regarding invoice styling. + | + */ + + 'invoices' => [ + // Supported: DompdfInvoiceRenderer::class, LaravelPdfInvoiceRenderer::class + 'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class), + + 'options' => [ + // Supported: 'letter', 'legal', 'A4' + 'paper' => env('CASHIER_PAPER', 'letter'), + + 'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Stripe Logger + |-------------------------------------------------------------------------- + | + | This setting defines which logging channel will be used by the Stripe + | library to write log messages. You are free to specify any of your + | logging channels listed inside the "logging" configuration file. + | + */ + + 'logger' => env('CASHIER_LOGGER'), + +]; diff --git a/config/domains.php b/config/domains.php index d53bf69..51f59e7 100644 --- a/config/domains.php +++ b/config/domains.php @@ -26,10 +26,10 @@ return [ 'domain_name' => env('APP_PORTAL_NAME', 'pressekonto.test'), 'url' => env('APP_PORTAL_URL', 'https://pressekonto.test'), 'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.pressekonto.test'), - 'theme' => 'main', + 'theme' => 'portal', 'view_prefix' => 'portal', 'assets_dir' => 'build/portal', - 'description' => 'Backend Pressekonto', + 'description' => 'Backend/Admin auf pressekonto.test', 'color_scheme' => [ 'primary' => env('APP_PORTAL_PRIMARY', '#526266'), // 'secondary' => env('APP_PORTAL_SECONDARY', '#82a0a7'), // diff --git a/config/models.php b/config/models.php index 31aaa81..eced959 100644 --- a/config/models.php +++ b/config/models.php @@ -1,5 +1,7 @@ Illuminate\Database\Eloquent\Model::class, + 'parent' => Model::class, /* |-------------------------------------------------------------------------- @@ -522,7 +524,7 @@ return [ 'path' => app_path('Models/Legacy/Presseecho'), 'namespace' => 'App\\Models\\Legacy\\Presseecho', 'connection' => true, - 'parent' => Illuminate\Database\Eloquent\Model::class, + 'parent' => Model::class, 'timestamps' => true, 'soft_deletes' => true, 'snake_attributes' => true, @@ -549,7 +551,7 @@ return [ 'path' => app_path('Models/Legacy/Presseecho'), 'namespace' => 'App\\Models\\Legacy\\Presseecho', 'connection' => true, - 'parent' => Illuminate\Database\Eloquent\Model::class, + 'parent' => Model::class, 'timestamps' => true, 'soft_deletes' => true, 'snake_attributes' => true, @@ -575,7 +577,7 @@ return [ 'path' => app_path('Models/Legacy/Businessportal'), 'namespace' => 'App\\Models\\Legacy\\Businessportal', 'connection' => true, - 'parent' => Illuminate\Database\Eloquent\Model::class, + 'parent' => Model::class, 'timestamps' => true, 'soft_deletes' => true, 'snake_attributes' => true, diff --git a/config/permission.php b/config/permission.php index 8e84e9d..0640deb 100644 --- a/config/permission.php +++ b/config/permission.php @@ -1,5 +1,9 @@ [ @@ -13,7 +17,7 @@ return [ * `Spatie\Permission\Contracts\Permission` contract. */ - 'permission' => Spatie\Permission\Models\Permission::class, + 'permission' => Permission::class, /* * When using the "HasRoles" trait from this package, we need to know which @@ -24,7 +28,7 @@ return [ * `Spatie\Permission\Contracts\Role` contract. */ - 'role' => Spatie\Permission\Models\Role::class, + 'role' => Role::class, ], @@ -136,7 +140,7 @@ return [ /* * The class to use to resolve the permissions team id */ - 'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class, + 'team_resolver' => DefaultTeamResolver::class, /* * Passport Client Credentials Grant @@ -183,7 +187,7 @@ return [ * When permissions or roles are updated the cache is flushed automatically. */ - 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + 'expiration_time' => DateInterval::createFromDateString('24 hours'), /* * The cache key used to store all permissions. diff --git a/config/sanctum.php b/config/sanctum.php index 44527d6..cde73cf 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -1,5 +1,8 @@ [ - 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, ], ]; diff --git a/config/scoring.php b/config/scoring.php new file mode 100644 index 0000000..e9236d0 --- /dev/null +++ b/config/scoring.php @@ -0,0 +1,71 @@ + [ + // Aktiver Treiber: openai|deterministic (Anthropic/Gemini folgen). + // Fällt der Anbieter aus (kein Key, Timeout, Fehler), greift im Job + // automatisch der deterministische Treiber. + 'provider' => env('CLASSIFICATION_PROVIDER', 'openai'), + + // Optional ein abweichendes Modell; leer => config('services.openai.model'). + 'model' => env('CLASSIFICATION_MODEL'), + + // Sekunden, bevor auf den deterministischen Fallback-Treiber + // ausgewichen wird (Timeout/Rate-Limit/Ausfall). + 'timeout' => (int) env('CLASSIFICATION_TIMEOUT', 15), + + // Verzögerung in Minuten für „grün" eingestufte PMs als + // Sicherheitsfenster vor der automatischen Veröffentlichung + // (Konzept-Option, 0 = sofort). + 'green_delay_minutes' => (int) env('CLASSIFICATION_GREEN_DELAY', 0), + + // Ob „gelb" eingestufte PMs in die manuelle Admin-Queue gehen. + 'yellow_to_manual_queue' => (bool) env('CLASSIFICATION_YELLOW_MANUAL', true), + ], + + /* + |-------------------------------------------------------------------------- + | Content-Score (Qualitätsbewertung, §15.2 / Update 2) + |-------------------------------------------------------------------------- + | + | Anbieter/Modell für die Score-Berechnung und Schwellen für die Ableitung + | der Stufe aus dem 0–100-Score (Standard < 60 ≤ Geprüft < 80 ≤ Hochwertig). + | Schwellen werden laut Konzept nach 100–200 echten PMs kalibriert. + | + */ + 'content_score' => [ + 'provider' => env('CONTENT_SCORE_PROVIDER', 'openai'), + 'model' => env('CONTENT_SCORE_MODEL'), + 'timeout' => (int) env('CONTENT_SCORE_TIMEOUT', 30), + + 'tiers' => [ + 'hochwertig' => (int) env('CONTENT_SCORE_HOCHWERTIG', 80), + 'gepruft' => (int) env('CONTENT_SCORE_GEPRUEFT', 60), + // alles darunter => 'standard' + ], + ], + +]; diff --git a/config/services.php b/config/services.php index 27a3617..3b354e8 100644 --- a/config/services.php +++ b/config/services.php @@ -34,5 +34,10 @@ return [ 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], - + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'), + 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), + 'timeout' => env('OPENAI_TIMEOUT', 60), + ], ]; diff --git a/database/factories/KiAuditFactory.php b/database/factories/KiAuditFactory.php new file mode 100644 index 0000000..7c99d88 --- /dev/null +++ b/database/factories/KiAuditFactory.php @@ -0,0 +1,53 @@ + + */ +class KiAuditFactory extends Factory +{ + protected $model = KiAudit::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $classification = fake()->randomElement(PressReleaseClassification::cases()); + + return [ + 'press_release_id' => PressRelease::factory(), + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'provider' => 'anthropic', + 'model' => 'claude-sonnet-4-6', + 'result' => $classification->value, + 'reason' => fake()->optional()->sentence(), + 'raw_response' => ['classification' => $classification->value, 'reasons' => []], + 'created_at' => now(), + ]; + } + + public function classification(PressReleaseClassification $classification): static + { + return $this->state(fn (): array => [ + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => $classification->value, + ]); + } + + public function contentScore(int $score): static + { + return $this->state(fn (): array => [ + 'type' => KiAudit::TYPE_CONTENT_SCORE, + 'result' => (string) $score, + ]); + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 0000000..0c68007 --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,36 @@ + + */ +class PlanFactory extends Factory +{ + protected $model = Plan::class; + + public function definition(): array + { + $monthly = fake()->numberBetween(19, 199) * 100; + + return [ + 'slug' => fake()->unique()->slug(2), + 'name' => fake()->words(2, true), + 'monthly_price_cents' => $monthly, + 'yearly_price_cents' => $monthly * 10, + 'currency' => 'EUR', + 'press_release_quota' => fake()->numberBetween(3, 60), + 'daily_limit' => null, + 'is_active' => true, + 'sort_order' => fake()->numberBetween(0, 10), + ]; + } + + public function inactive(): static + { + return $this->state(fn (): array => ['is_active' => false]); + } +} diff --git a/database/factories/SinglePurchaseFactory.php b/database/factories/SinglePurchaseFactory.php new file mode 100644 index 0000000..fae6fd4 --- /dev/null +++ b/database/factories/SinglePurchaseFactory.php @@ -0,0 +1,45 @@ + + */ +class SinglePurchaseFactory extends Factory +{ + protected $model = SinglePurchase::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => SinglePurchaseType::SinglePm, + 'status' => SinglePurchaseStatus::Pending, + 'price_cents' => 1900, + 'currency' => 'EUR', + ]; + } + + public function paid(): static + { + return $this->state(fn (): array => [ + 'status' => SinglePurchaseStatus::Paid, + 'paid_at' => now(), + ]); + } + + public function consumed(): static + { + return $this->state(fn (): array => [ + 'status' => SinglePurchaseStatus::Consumed, + 'paid_at' => now()->subDay(), + 'consumed_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php b/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php new file mode 100644 index 0000000..81a1cb6 --- /dev/null +++ b/database/migrations/2026_05_29_130110_add_placeholder_variant_to_press_releases.php @@ -0,0 +1,28 @@ +string('placeholder_variant', 32)->nullable()->after('boilerplate_override'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('placeholder_variant'); + }); + } +}; diff --git a/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php b/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php new file mode 100644 index 0000000..0dbb4f1 --- /dev/null +++ b/database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php @@ -0,0 +1,32 @@ +string('author')->nullable()->after('copyright'); + $table->string('license_type', 32)->nullable()->after('author'); + $table->string('license_url')->nullable()->after('license_type'); + $table->boolean('persons_consent')->default(false)->after('license_url'); + $table->timestamp('rights_confirmed_at')->nullable()->after('persons_consent'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropColumn(['author', 'license_type', 'license_url', 'persons_consent', 'rights_confirmed_at']); + }); + } +}; diff --git a/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php b/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php new file mode 100644 index 0000000..4c5cf1a --- /dev/null +++ b/database/migrations/2026_05_29_134459_add_press_release_quota_to_users.php @@ -0,0 +1,29 @@ +unsignedInteger('press_release_quota')->default(3); + $table->unsignedInteger('press_release_quota_used_this_month')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['press_release_quota', 'press_release_quota_used_this_month']); + }); + } +}; diff --git a/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php b/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php new file mode 100644 index 0000000..59446cf --- /dev/null +++ b/database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php @@ -0,0 +1,38 @@ +string('license_detail', 120)->nullable()->after('license_type'); + $table->string('source_url', 2048)->nullable()->after('license_url'); + $table->string('people_rights_status', 40)->nullable()->after('persons_consent'); + $table->string('property_rights_status', 40)->nullable()->after('people_rights_status'); + $table->text('rights_notes')->nullable()->after('property_rights_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropColumn([ + 'license_detail', + 'source_url', + 'people_rights_status', + 'property_rights_status', + 'rights_notes', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php b/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php new file mode 100644 index 0000000..b9ddf2a --- /dev/null +++ b/database/migrations/2026_06_11_131506_add_classification_to_press_releases.php @@ -0,0 +1,34 @@ +string('classification', 16)->nullable()->after('status'); + $table->timestamp('classified_at')->nullable()->after('classification'); + + $table->index('classification', 'press_releases_classification_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + $table->dropIndex('press_releases_classification_idx'); + $table->dropColumn(['classification', 'classified_at']); + }); + } +}; diff --git a/database/migrations/2026_06_11_131506_create_ki_audits_table.php b/database/migrations/2026_06_11_131506_create_ki_audits_table.php new file mode 100644 index 0000000..2cb341e --- /dev/null +++ b/database/migrations/2026_06_11_131506_create_ki_audits_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('press_release_id') + ->constrained() + ->cascadeOnDelete(); + // classification | content_score — welche KI-Bewertung dies protokolliert. + $table->string('type', 32); + $table->string('provider', 32)->nullable(); + $table->string('model', 64)->nullable(); + // Ergebnis als String (z. B. green/yellow/red) oder Score-Wert. + $table->string('result', 64)->nullable(); + $table->text('reason')->nullable(); + // Vollständige Roh-Antwort der KI für Nachvollziehbarkeit (DSGVO). + $table->json('raw_response')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['press_release_id', 'type'], 'ki_audits_pr_type_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ki_audits'); + } +}; diff --git a/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php b/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php new file mode 100644 index 0000000..b3c59df --- /dev/null +++ b/database/migrations/2026_06_11_150538_add_content_score_to_press_releases.php @@ -0,0 +1,36 @@ +unsignedTinyInteger('content_score')->nullable()->after('classified_at'); + $table->string('content_tier', 16)->nullable()->after('content_score'); + $table->timestamp('scored_at')->nullable()->after('content_tier'); + + $table->index('content_tier', 'press_releases_content_tier_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table): void { + $table->dropIndex('press_releases_content_tier_idx'); + $table->dropColumn(['content_score', 'content_tier', 'scored_at']); + }); + } +}; diff --git a/database/migrations/2026_06_12_100632_create_customer_columns.php b/database/migrations/2026_06_12_100632_create_customer_columns.php new file mode 100644 index 0000000..974b381 --- /dev/null +++ b/database/migrations/2026_06_12_100632_create_customer_columns.php @@ -0,0 +1,40 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex([ + 'stripe_id', + ]); + + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_12_100633_create_subscriptions_table.php b/database/migrations/2026_06_12_100633_create_subscriptions_table.php new file mode 100644 index 0000000..ccbcc6d --- /dev/null +++ b/database/migrations/2026_06_12_100633_create_subscriptions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'stripe_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2026_06_12_100634_create_subscription_items_table.php b/database/migrations/2026_06_12_100634_create_subscription_items_table.php new file mode 100644 index 0000000..420e23f --- /dev/null +++ b/database/migrations/2026_06_12_100634_create_subscription_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->timestamps(); + + $table->index(['subscription_id', 'stripe_price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscription_items'); + } +}; diff --git a/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php new file mode 100644 index 0000000..033bb82 --- /dev/null +++ b/database/migrations/2026_06_12_100635_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_id')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_id'); + }); + } +}; diff --git a/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 0000000..b157b3a --- /dev/null +++ b/database/migrations/2026_06_12_100636_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_event_name')->nullable()->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_event_name'); + }); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php b/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php new file mode 100644 index 0000000..46d33a1 --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_invoice_number_sequences_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('circle', 8)->unique(); + $table->unsignedBigInteger('next_number')->default(1); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_number_sequences'); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_plans_table.php b/database/migrations/2026_06_12_100724_create_plans_table.php new file mode 100644 index 0000000..377a696 --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_plans_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('slug', 40)->unique(); + $table->string('name', 80); + $table->unsignedInteger('monthly_price_cents'); + $table->unsignedInteger('yearly_price_cents'); + $table->string('currency', 3)->default('EUR'); + $table->unsignedInteger('press_release_quota'); + $table->unsignedInteger('daily_limit')->nullable(); + $table->string('stripe_product_id', 60)->nullable(); + $table->string('stripe_price_id_monthly', 60)->nullable(); + $table->string('stripe_price_id_yearly', 60)->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['is_active', 'sort_order'], 'plans_active_sort_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_06_12_100724_create_single_purchases_table.php b/database/migrations/2026_06_12_100724_create_single_purchases_table.php new file mode 100644 index 0000000..1c5d8cf --- /dev/null +++ b/database/migrations/2026_06_12_100724_create_single_purchases_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('type', array_map( + static fn (SinglePurchaseType $type): string => $type->value, + SinglePurchaseType::cases() + )); + $table->enum('status', array_map( + static fn (SinglePurchaseStatus $status): string => $status->value, + SinglePurchaseStatus::cases() + ))->default(SinglePurchaseStatus::Pending->value); + $table->unsignedInteger('price_cents'); + $table->string('currency', 3)->default('EUR'); + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->string('stripe_checkout_session_id', 80)->nullable(); + $table->string('stripe_payment_intent_id', 80)->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('consumed_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'type', 'status'], 'single_purchases_user_type_status_idx'); + $table->index(['stripe_checkout_session_id'], 'single_purchases_stripe_session_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('single_purchases'); + } +}; diff --git a/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php b/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php new file mode 100644 index 0000000..e5a0799 --- /dev/null +++ b/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php @@ -0,0 +1,45 @@ +string('vat_id', 20)->nullable()->after('country_code'); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table) { + $table->string('vat_id', 20)->nullable()->after('country_code'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->string('tax_note', 191)->nullable()->after('is_netto'); + }); + } + + public function down(): void + { + Schema::table('billing_addresses', function (Blueprint $table) { + $table->dropColumn('vat_id'); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table) { + $table->dropColumn('vat_id'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('tax_note'); + }); + } +}; diff --git a/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php b/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php new file mode 100644 index 0000000..95e4641 --- /dev/null +++ b/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php @@ -0,0 +1,27 @@ +dropColumn('press_release_quota'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->unsignedInteger('press_release_quota')->default(3)->after('legacy_id'); + }); + } +}; diff --git a/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php b/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php new file mode 100644 index 0000000..a467f72 --- /dev/null +++ b/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php @@ -0,0 +1,40 @@ +string('company', 255)->nullable()->after('title'); + $table->string('first_name', 80)->nullable()->after('company'); + $table->string('last_name', 80)->nullable()->after('first_name'); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table): void { + $table->string('company', 255)->nullable()->after('title'); + }); + } + + public function down(): void + { + Schema::table('billing_addresses', function (Blueprint $table): void { + $table->dropColumn(['company', 'first_name', 'last_name']); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table): void { + $table->dropColumn('company'); + }); + } +}; diff --git a/database/migrations/2026_06_12_160000_add_is_ai_generated_to_press_release_images.php b/database/migrations/2026_06_12_160000_add_is_ai_generated_to_press_release_images.php new file mode 100644 index 0000000..020ebe8 --- /dev/null +++ b/database/migrations/2026_06_12_160000_add_is_ai_generated_to_press_release_images.php @@ -0,0 +1,28 @@ +boolean('is_ai_generated')->default(false)->after('rights_confirmed_at'); + }); + } + + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table): void { + $table->dropColumn('is_ai_generated'); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php new file mode 100644 index 0000000..417b92c --- /dev/null +++ b/database/seeders/PlanSeeder.php @@ -0,0 +1,36 @@ + 'starter', 'name' => 'Starter', 'monthly_price_cents' => 2900, 'press_release_quota' => 3, 'daily_limit' => null, 'sort_order' => 1], + ['slug' => 'business', 'name' => 'Business', 'monthly_price_cents' => 4900, 'press_release_quota' => 10, 'daily_limit' => 2, 'sort_order' => 2], + ['slug' => 'pro', 'name' => 'Pro', 'monthly_price_cents' => 9900, 'press_release_quota' => 25, 'daily_limit' => 3, 'sort_order' => 3], + ['slug' => 'agency', 'name' => 'Agency', 'monthly_price_cents' => 19900, 'press_release_quota' => 60, 'daily_limit' => 5, 'sort_order' => 4], + ]; + + foreach ($plans as $plan) { + Plan::query()->updateOrCreate( + ['slug' => $plan['slug']], + [ + ...$plan, + 'yearly_price_cents' => $plan['monthly_price_cents'] * 10, + 'currency' => 'EUR', + 'is_active' => true, + ], + ); + } + } +} diff --git a/dev/frontend/hub-flux/03-WEITERE-PHASEN.md b/dev/frontend/hub-flux/03-WEITERE-PHASEN.md index 0ff92e3..8d29c85 100644 --- a/dev/frontend/hub-flux/03-WEITERE-PHASEN.md +++ b/dev/frontend/hub-flux/03-WEITERE-PHASEN.md @@ -187,7 +187,7 @@ aber: --- -## Gesamt-Status (Stand 2026-05-20) +## Gesamt-Status (Stand 2026-05-29) | Phase | Inhalt | Status | |---|---|---| @@ -198,6 +198,7 @@ aber: | 4 | Listen/Detail durchgehen (4A–4J) | ✅ **komplett** | | 5 | Dark Mode konsistent | ✅ **abgeschlossen** | | 6 | Auth-Cleanup | ✅ **abgeschlossen** | +| 7 | Press-Release-Form-Refactor (7A–7F) | ✅ **abgeschlossen** (`19-PHASE-7-…`) | ### Phase 4 — Sub-Päckchen im Detail @@ -219,13 +220,21 @@ aber: > Die hub-flux-Roadmap ist mit Phase 6 **vollständig** abgeschlossen. > Alle weiteren Themen sind eigene Initiativen. -**🟡 In Planung — Phase 7 (Press-Release-Form-Refactor):** -Mockup `User Neue Mitteilung presseportale.html` wird auf den +**✅ Abgeschlossen — Phase 7 (Press-Release-Form-Refactor):** +Mockup `User Neue Mitteilung presseportale.html` auf den Customer-Create/Edit-Flow übertragen. Plan-Doc: -`19-PHASE-7-PRESS-RELEASE-FORM.md`. -Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) → -7C (Customer-Create-UI) → 7D (Customer-Edit-UI) → -7E (Anhänge-Manager) → 7F (Scheduling/Embargo, optional). +`19-PHASE-7-PRESS-RELEASE-FORM.md`. Alle Päckchen umgesetzt: +7A (Migrations: subtitle/boilerplate_override/scheduled_at/ +embargo_at + attachments-Tabelle + companies.boilerplate) → +7B (`flux:editor` + `PressReleaseHtmlSanitizer` via mews/purifier) → +7C (Customer-Create-UI, 2-Spalter mit sticky Settings-Sidebar) → +7D (Customer-Edit-UI) → 7E (Anhänge-Manager) → +7F (Scheduling/Embargo + `press-releases:publish-scheduled` +Command + 5-Min-Scheduler). Admin-Create/Edit ziehen optisch mit; +Scheduling-UI bleibt Customer-seitig. Detail siehe `PROGRESS.md` +(Eintrag 2026-05-22). + +### Offene Folge-Initiativen 1. **Manueller Dark-Mode Smoke-Test**: Im Browser User-Menü → Erscheinung → „Dunkel" und durch die Hauptseiten klicken @@ -233,10 +242,10 @@ Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) → Lesbar + konsistent. Kleine Polish-Runde, falls visuelle Auffälligkeiten. -2. **PM-Form-Wizard-Refactor**: Mockup - `User Neue Mitteilung presseportale.html` auf den bestehenden - Press-Release-Create/Edit-Flow übertragen. Größere Aktion mit - eigener Phase. +2. **Admin-Create/Edit nachziehen**: Die Admin-PM-Forms haben das + Phase-7-Layout optisch übernommen, aber Scheduling/Embargo-UI + ist bewusst Customer-seitig geblieben. Bei Bedarf als kleines + Folge-Päckchen für Admins ergänzen. 3. **Web-Frontend-Block** (eigenständig, NICHT Teil von Phase 1–6): Die noch ungenutzten Mockups diff --git a/dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md b/dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md new file mode 100644 index 0000000..d95e934 --- /dev/null +++ b/dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md @@ -0,0 +1,103 @@ +# Phase 8 · User-Panel-Konsolidierung & PM-Lifecycle + +Stand: 2026-05-29 · **abgeschlossen** + +Plan-Doc: [`docs/PHASE-8-USER-PANEL-PLAN.md`](../../../docs/PHASE-8-USER-PANEL-PLAN.md) +Abgleich: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](../../../docs/STATUS-ABGLEICH-USER-PANEL.md) + +Folge-Initiative nach Phase 7 (PM-Form-Refactor). Ziel: das User-Panel +produktionsreif abrunden — Show-Page-Lücken, Firmen-Liste auf +Mockup-Niveau, durchgängige PM-Titelbilder, rechtssichere Bild-Lizenzen +und ein bewusster Veröffentlichungs-Flow mit Kontingent-Vorbereitung. + +## Päckchen-Übersicht + +| ID | Thema | Status | +|---|---|---| +| 8A | Show-Page-Lücken (subtitle, scheduling, embargo, boilerplate_override) — Customer + Admin | ✅ | +| 8B | Listen-Indikatoren für Scheduling/Embargo | ✅ | +| 8C | Pressekontakt-Warn-Box in der Form-Sidebar | ✅ | +| 8D | Doku-Sync (`docs/user-admin/*`, `STATUS-ABGLEICH`) | ✅ | +| 8E | Firmen-Liste auf Mockup-Niveau (Counter-Strip, Saved-Views, Filter-Chips, Toggle, Rollen-Legende) | ✅ | +| 8F | SVG-Titelbild-Platzhalter-Set + Anzeige-Komponente + Picker-Modal | ✅ | +| 8G | Titelbild-Schema (`placeholder_variant`) + Cover-Resolver | ✅ | +| 8H | Bild-Upload mit Lizenz-Pflichtfeldern | ✅ | +| 8I | Veröffentlichungs-Modal (Rechtshinweis + Kontingent) | ✅ | +| 8J | Quota-Stub im Datenmodell + monatlicher Reset-Command | ✅ | +| 8K | Tests, Pint, Build, Doku-Abschluss | ✅ | + +## Wichtigste Code-Artefakte (8F–8J, neu in dieser Phase) + +**Enums** +- `app/Enums/PressReleasePlaceholder.php` — 9 SVG-Varianten (Default, + Seed-deterministisch, Labels, Asset-Pfad). +- `app/Enums/ImageLicenseType.php` — 5 Lizenztypen, `requiresLicenseUrl()`. + +**Assets / Komponenten** +- `public/images/press-release-placeholders/01..09-*.svg` (1600×900, + 3 Muster × 3 Hub-Farben). +- `resources/views/components/portal/press-release-placeholder.blade.php` — + rendert Bild/Platzhalter mit optionalem Titel-Overlay. +- `resources/views/livewire/components/press-release-placeholder-picker.blade.php` — + FluxUI-Modal mit 3×3-Grid, dispatcht `placeholder-selected`. + +**Services** +- `app/Services/PressRelease/PressReleaseCoverImage.php` — `coverUrl()`, + `coverIsPlaceholder()`, `placeholder()`. Bevorzugt `is_preview`-Bild, + fällt sonst auf den SVG-Platzhalter zurück. + +**Schema (additive, nullable/Default-Migrationen)** +- `press_releases.placeholder_variant` (string 32, nullable). +- `press_release_images`: `author`, `license_type`, `license_url`, + `persons_consent` (default false), `rights_confirmed_at`. +- `users`: `press_release_quota` (default 3), + `press_release_quota_used_this_month` (default 0). + +**Models** +- `PressRelease`: Cast + `creating`-Hook setzt deterministisch eine + Platzhalter-Variante, wenn keine gesetzt ist. +- `PressReleaseImage`: Lizenz-Felder + `license_type`-Enum-Cast. +- `User`: `pressReleaseQuotaRemaining()`. + +**Service-Hook & Command** +- `PressReleaseService::submitForReview()` zählt + `press_release_quota_used_this_month` des Autors hoch (Stub). +- `app/Console/Commands/ResetMonthlyPressReleaseQuota.php` + + Scheduler-Eintrag (`monthlyOn(1, '00:05')`) in `routes/console.php`. + +**Views** +- Customer-Show + Admin-Show: Hero-Titelbild via Cover-Resolver. +- Customer Create/Edit: Platzhalter-Vorschau + Picker-Einbindung + (`#[On('placeholder-selected')]`). +- Customer-Show: Veröffentlichungs-Modal (`confirm-submit-review`) mit + Rechts-Platzhalter, Kontingent-Badge und 3 Bestätigungs-Checkboxen + (Submit-Button via Alpine disabled bis alle gesetzt). +- Image-Manager: Lizenz-Felder + Anzeige in der Bild-Kachel. + +## Tests (neu) + +- `tests/Feature/PressReleasePlaceholderTest.php` (8) — Enum, Assets, + Picker, Cover-Resolver (Platzhalter + echtes Bild). +- `tests/Feature/PressReleaseImageLicenseTest.php` (3) — Pflichtfelder, + CC-ohne-URL, vollständiger Upload + `rights_confirmed_at`. +- `tests/Feature/PressReleaseQuotaTest.php` (3) — Remaining-Berechnung, + Increment bei Submit, monatlicher Reset. +- `tests/Feature/PressReleasePublishModalPhase8iTest.php` (2) — Modal-Inhalt + (Rechtshinweis + Kontingent), Submit-Flow. + +Gesamt-Suite nach Phase 8: **375 passed, 4 skipped**. Pint clean, +`npm run build:portal` clean. + +## Bewusste Abweichungen / offene Folge-Themen + +- **8H — Upload-Control**: `flux:input type=file` statt `flux:file-upload` + (Pro-Dropzone mit aufwändigem Slot-Aufbau). Funktion identisch, kein + Risiko für den Upload-Flow. Dropzone-Optik ggf. separat nachziehen. +- **8I — Rechtstext** ist ein **Platzhalter** und vor Go-Live anwaltlich + zu prüfen. +- **8J — Quota** ist ein Stub. Die Schnittstelle + `User::pressReleaseQuotaRemaining()` bleibt stabil; das echte + Tarif-/Credit-Modul löst Spalten + Decrement-Logik später ab. +- Offen (Phase 2/3): Stock-/KI-Bildquellen, Wasserzeichen-Check, + Magic-Link-Flow für Pressekontakte, Statistik-/Abrechnungs-Tabs, + Anhänge-Reaktivierung (Security-Audit). diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index bbea9aa..31d18ea 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,517 @@ --- +## 2026-06-12 · Tagesabschluss · PM-Vorschau-Feinschliff + Next Steps ✅ + +- **Was**: Letzte Review-Runde auf der PM-Vorschauseite: Sprache-Kachel + entfernt (Kürzel jetzt als Badge am Portal-Namen), Panel „Zugeordnete + Pressekontakte" zu **„Firma & Pressekontakt"** zusammengeführt — oben + die Firma als vollwertige `firm-card` wie in der Firmenübersicht + (Logo/Initialen, Name + Meta-Zeile, Portal-Pills, Aktiv-Badge, + PM-/Kontakt-KPIs, „Firma öffnen"), direkt darunter der zugeordnete + Pressekontakt bzw. der Leerhinweis. +- **Dateien**: `customer/press-releases/show.blade.php`, + `docs/README.md` (Stand + neuer `weiteres/`-Abschnitt). +- **Build/Test**: Suite 557 passed / 4 skipped, Pint clean. +- **Hinweis Doku**: Das umgesetzte Verlinkungs-Decision-Update wurde von + Kevin aus `docs/weiteres/` entfernt (Inhalt in §4 des + Preisstruktur-Decision-Updates integriert, Umsetzungsstand hier im Log). +- **Nächste Schritte (vereinbart 12.06.2026)**: + 1. **Decision-Update „Phase-2-Funktionen & Magic-Link-Änderungsprozess"** + umsetzen (Boost + Veröffentlichungsnachweis für den Launch, + Magic-Link-Zugangs-/Änderungsprozess; überschneidet sich mit 9G + Tageslimit und 9I Launch-Credits). + 2. **Decision-Update „Duplicate-Content & Duplicate-Checking"** umsetzen + (Cross-Portal-SEO vs. Duplikat-Erkennung eingereichter PMs). + 3. Danach weiter laut Kevins Liste: Login-/Registrierungs-Flow + durchtesten (Zweig 3), Code-Optimierung (Zweig 4). + +## 2026-06-12 · KI-generierte Bilder: Lizenztyp + Kennzeichnung ✅ + +- **Was**: Neuer Lizenztyp „KI-generiert" im Titelbild-Upload + (Entscheidung nach Kevins Frage): KI-Bilder haben keinen menschlichen + Urheber (§ 2 UrhG) — maßgeblich sind die Anbieter-Bedingungen, und ab + 02.08.2026 greift die Kennzeichnungspflicht aus Art. 50 EU AI Act. + Formular-Schaltung bei Auswahl: Pflichtfeld „Verwendetes KI-Tool" + (statt CC-/Sonstiges-Detail), Urheber-Label wird „Verantwortlich für + die Erstellung", Hinweis-Box (kein Urheberrecht, Anbieter-Bedingungen, + öffentliche Kennzeichnung, keine realen Personen/Marken), zusätzlicher + Pflicht-Switch „Anbieter-Bedingungen geprüft". Der öffentliche + Bildnachweis wird automatisch vorgeschlagen („Bild: KI-generiert + (Tool)"), manuelle Eingaben werden nicht überschrieben. Neues Flag + `press_release_images.is_ai_generated` (Migration); Kennzeichnung + sichtbar als Badge im Upload-Manager und als Bildnachweis-Zeile unter + dem Titelbild auf Customer- und Admin-Detailseite — die öffentlichen + Portal-Seiten übernehmen das Label beim Web-Relaunch. +- **Dateien**: `app/Enums/ImageLicenseType.php` (AiGenerated), + `press-release-images-manager.blade.php`, `PressReleaseImage`-Model, + Migration, Customer-/Admin-Show (Bildnachweis-Zeile). +- **Build/Test**: 3 neue Tests (Pflichtfelder, Speichern inkl. Flag, + Nachweis-Vorschlag), Suite 557 passed / 4 skipped, Pint clean. +- **Offene Fragen**: Label-Ausspielung auf den öffentlichen Web-Seiten + beim Relaunch (Flag + copyright sind vorhanden). + +## 2026-06-12 · Titelbild-Upload: Struktur + große Vorschau ✅ + +- **Was**: Das Bildrechte-Formular im Titelbild-Upload (gemeinsame + Komponente `press-release-images-manager`, Admin + Customer) war eine + lange flache Feldliste — jetzt fünf nummerierte Schritte mit + Trennlinien: 1 Bild auswählen, 2 Bildinformationen (öffentlich), + 3 Herkunft & Lizenz (Pflicht-Badges, 2-Spalten-Grid), 4 Personen & + Rechte Dritter (Radio-Gruppen nebeneinander), 5 Bestätigung. Nach der + Bildauswahl erscheint statt des Mini-Thumbnails eine **große + 16:9-Vorschau** im Titelbild-Format (mit Dateiname/Größe und „Anderes + Bild wählen"); die Dropzone weicht der Vorschau, bis das Bild + verworfen wird. +- **Dateien**: `resources/views/livewire/components/press-release-images-manager.blade.php`. +- **Build/Test**: PressReleaseImageLicenseTest 12 passed, Suite 552 + passed / 4 skipped, Pint clean. + +## 2026-06-12 · Verlinkung & Backlinks (Decision-Update 11.06.) ✅ + +- **Was**: Systemseitige `rel`-Auszeichnung für Links in PMs umgesetzt: + neue `PressReleaseLinkPolicy`, angewendet in + `PressReleaseHtmlSanitizer::render()` (wirkt rückwirkend auf alle + gespeicherten Inhalte). Externe Links → `sponsored nofollow noopener` + + `target="_blank"`, portalinterne Links (drei Domains inkl. www, + relative Pfade) → follow, mailto/tel ohne rel/target. Autoren-`rel` + wird immer überschrieben — kein kundenseitiger follow/nofollow-Hebel. + Editor-Hinweis unter der Schreibfläche (Create/Edit). §9-Korrektur im + Preisstruktur-Decision-Update eingearbeitet, Umsetzungsstand im + Verlinkungs-Dokument ergänzt. +- **Dateien**: `app/Services/PressRelease/PressReleaseLinkPolicy.php` + (neu), `PressReleaseHtmlSanitizer.php`, Editor-Views (Create/Edit), + beide Decision-Dokumente. +- **Build/Test**: 6 neue Policy-Tests in `PressReleaseHtmlSanitizerTest`. +- **Offene Fragen**: CTA-Box/Darstellungs-Stufung → Boost-Konzept (9I); + Link-Obergrenze pro PM und Anchor-Text-Soft-Check → Phase 2. + +## 2026-06-12 · PM-Vorschau-Umbau + Profil-Feinschliff ✅ + +- **Was**: (1) PM-Detailseite (Customer) nach Kevins Review umgebaut: + Status-Workflow direkt unter den Header, farblich abgehoben + (Soft-Hintergrund + 3px-Border je Status: Entwurf hub, Überarbeiten + err, In Prüfung warn, neu: Veröffentlicht ok); danach Pressekontakte + + Status & Verlauf, dann erst Titelbild und Inhalt. Metadaten ergänzt: + Portal, Kategorie, Sprache als Kacheln, Themen (Keywords) als Badges, + Backlink — alles im Verlaufs-Panel. (2) **Inhalts-Typografie**: Die + Detailseiten nutzten `prose`-Klassen, aber das Tailwind-Typography- + Plugin ist gar nicht installiert → Text erschien unformatiert (Diff + Editor vs. Vorschau). Neue Klasse `.pr-content` in hub-components.css + bildet die Editor-Typografie nach (Absätze, Listen, H2-H4, Links, + Blockquote); Customer- UND Admin-Detailseite umgestellt, Lesebreite + max 760px. (3) Profil: Pflicht-Badges an Pflichtfeldern, Fokus-Fix + nach dem Speichern (blur statt Autofokus auf „Anzeigename"), + Submit-Modal-Bestätigungen von rohen Checkboxen auf Flux-Switches + (Alpine-State via change-Event, Button-Freischaltung unverändert). +- **Dateien**: `customer/press-releases/show.blade.php` (Umbau), + `admin/press-releases/show.blade.php` (.pr-content), + `resources/css/shared/hub-components.css` (.pr-content), + `customer/profile.blade.php`, `components/press-release-submit-modal.blade.php`. +- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean, npm run build ok. +- **Offene Fragen**: Web-Detailseiten (`web/release-detail` u. a.) nutzen + ebenfalls wirkungslose `prose`-Klassen — beim Web-Relaunch auf + `.pr-content` (oder Typography-Plugin) umstellen. + +## 2026-06-12 · User-Panel-Restarbeiten (Kevins Liste) ✅ + +- **Was**: Alle Punkte aus `docs/user-admin/User-Panel-Restarbeiten.md` + (Status-Tabelle dort): (1) PM-Anlage ohne Firma zeigt eine Meldung mit + „Firma anlegen"-Button statt des leeren Editors. (2) Profil-Seite neu + gegliedert (Persönliche Daten / Konto / Rechnungsadresse / Einstellungen); + Rechnungsadresse vervollständigt um Anrede, Vorname, Nachname, + Firmenname (Migration `billing_addresses` + Snapshot-Spalte `company` + in `invoice_billing_addresses`, von MAN-Lauf und STR-Spiegelung + durchgereicht); „Persönliche Daten übernehmen"-Button gegen die + Doppel-Eingabe. (3) Doppelte Validierungsmeldung behoben: fehlende + Pflichtfelder melden einzeln unter dem Feld. (4) USt-ID-Validierung: + Formatprüfung sofort (hartes Speicher-Gate) + eVatR-Online-Bestätigung + (BZSt-REST-API, neuer `VatIdValidationService`, ENV `BILLING_OWN_VAT_ID`, + 6h-Cache nur für definitive Ergebnisse, Ausfälle degradieren sanft). + (5) Rechnungsadresse ist Pflicht für jeden Checkout (Redirect aufs + Profil mit Hinweis); USt-ID wird als Stripe-Tax-ID übergeben. + (6) Firmenübersicht zeigt Logos auch für Legacy-Firmen (zentrale + logoUrl-Auflösung statt verkürzter Fast-Variante); „Letzte PM" mit + Jahreszahl. (7) Checkboxen → Flux-Switches (Boilerplate-Override, + Footer-Code); Token-Abilities bleiben Checkbox-Gruppe. Befund zu den + Profil-Schaltern: `show_stats`/`disable_footer_code` werden noch + nirgends ausgewertet (greifen mit Web-Relaunch) — steht jetzt in den + Beschreibungen. +- **Dateien**: `customer/profile.blade.php` (Neufassung), + `customer/press-releases/create.blade.php` (Guard), + `customer/press-kits/index.blade.php` (Logo/Datum), + `app/Services/Billing/VatIdValidationService.php` (neu) + + `VatIdCheckStatus`-Enum, `VatResolver` (isPlausibleVatId public), + `StripeCheckoutService` (Tax-ID-Sync), `CheckoutController` (Guard), + `User::hasCompleteBillingAddress()`, `BillingAddress::isComplete()`, + Migration, `config/billing.php` (`own_vat_id`). +- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean; 14 neue Tests + (VatId-Service, PM-Guard, Profil-Validierung, Checkout-Guard). +- **Offene Fragen**: `BILLING_OWN_VAT_ID` in .env setzen (eigene USt-ID), + sonst bleibt die Online-Prüfung aus; Entscheidung zu den beiden + Profil-Schaltern (behalten vs. bis Relaunch ausblenden). + +## 2026-06-12 · Responsive-Härtung (Block 3, Punkt 1) ✅ + +- **Was**: Systemische Responsive-Fehler behoben (Screenshots in + `dev/frontend/responsive/`): (1) Das starre Inline-Grid + `style="grid-template-columns:1fr auto"` der Seiten-Header (48 Seiten) + konnte nie umbrechen — die Aktions-Spalte quetschte die Titel-Spalte + (Buchstaben-Umbrüche im Firmennamen, Buttons liefen aus der Box). + Ersetzt durch die neue CSS-Klasse `.page-header` (hub-components.css): + unterhalb lg stapeln Titel und Aktionen, Aktions-Zeilen bekommen + flex-wrap. (2) Firmenkontext-Leiste im App-Layout stapelt jetzt erst + ab lg nebeneinander (vorher sm → Überlappung von „Firmenkontext"/ + „Aktive Firma" zwischen 640–1024px). (3) Stat-Cards: Label/Meta-Zeile + mit flex-wrap (keine Kollision mehr auf schmalen Karten), `.stat-num` + mit `overflow-wrap:anywhere` + 26px unter 480px (Text-Werte wie + „Businessportal24" sprengen die Karte nicht mehr). (4) KPI-Grids, die + schon ab sm auf 4 Spalten gingen (Customer-Dashboard, User-Show, + PM-Index), erst ab xl vierspaltig. +- **Dateien**: `resources/css/shared/hub-components.css` (.page-header, + .stat-num), `resources/views/components/portal/stat-card.blade.php`, + `resources/views/components/layouts/app.blade.php`, 48 Seiten-Views + (Header-Klasse per sed), 3 KPI-Grids. +- **Build/Test**: `npm run build` ok (beide Bundles), Suite 532 passed / + 4 skipped. +- **Offene Fragen**: Weitere Stellen aus Kevins Klick-Durchgang folgen + (Liste Block 3). + +## 2026-06-12 · Admin-Zahlungsmodul (P8-Rest) · Zahlungen + Tarif-Verwaltung ✅ + +- **Was**: Den Phase-8-Platzhalter `/admin/payments` durch das echte + Zahlungsmodul ersetzt: KPI-Reihe (aktive Abos, MRR netto, Umsatz + 30 Tage brutto, offene Einzel-PMs), Tabellen für Stripe-Abos (mit + Tarif-Auflösung über die Price-IDs), Einmalkäufe (Typ/Status/PM-Link) + und den lokalen Rechnungsausgang (STR-/MAN-Badge), User-Suche über + alle drei Bereiche. Neu: `/admin/payments/plans` — Tarif-Verwaltung + mit Edit-Modal (Name, Netto-Preise, PM-Kontingent, Tageslimit, + aktiv/inaktiv, Sortierung) und **Sofort-Sync nach Stripe** über den + neuen `StripePlanSyncService`: Preisänderung legt ein neues + Price-Objekt an und deaktiviert das alte (Stripe-Preise sind + unveränderlich), Namensänderung aktualisiert das Produkt, unverknüpfte + Tarife werden komplett angelegt. Bestandsabos behalten ihren Preis + (Hinweis in UI und Speichermeldung). Buchungs-Seite zieht die Preise + ohnehin live aus `plans` → Änderungen wirken sofort überall. + Sidebar: eigener Eintrag „Tarife & Pakete" unter Billing. +- **Dateien**: `resources/views/livewire/admin/payments/index.blade.php` + (Neufassung), `resources/views/livewire/admin/payments/plans.blade.php` + (neu), `app/Services/Billing/StripePlanSyncService.php` (neu), + `routes/admin.php`, Sidebar. +- **Build/Test**: Suite 532 passed / 4 skipped, Pint clean; 13 neue Tests + (`AdminPlansPageTest`, `AdminPaymentsPageTest`), Stripe im Test gemockt. +- **Offene Fragen**: Refund-Workflow aus dem Admin (vorerst über das + Stripe-Dashboard); Einzel-PM-Preis bleibt Config/ENV-basiert. +- **Nächster Schritt**: User-Panel-Restarbeiten (Kevin sammelt Liste), + Login/Registrierungs-Flow durchtesten, 9G Tageslimit. + +## 2026-06-12 · Phase 9F · Tarif-Seite + Checkout-UI ✅ + +- **Was**: „Buchungen & Add-ons" vom Credit-Konzept-Mock auf echte Daten + umgestellt: 4-Tier-Raster aus `plans` (Alpine Monat/Jahr-Toggle, + „2 Monate gratis"), Checkout-Buttons auf die 9E-Routen, Einzel-PM als + separater No-Abo-Block, Aktueller-Tarif-Panel (Abo / Bestandstarif + unbegrenzt / offene Einzelkäufe / leer) mit Kontingent-Kachel, + „Abo verwalten" → Stripe Billing Portal (neue Route + `me.checkout.billing-portal`), aktive Buchungen + Verlauf real. + Credit-Pakete/Marktplatz/Platzierungen entfernt (→ 9I bzw. Phase 2). + Stripe Tax im Dashboard aktiviert („SaaS – business use", exklusiv). + **Feinschliff nach Review (Kevin)**: Aktueller-Tarif-Card nur bei + vorhandener Buchung (kein irreführendes „Unbegrenzt" vor dem Launch; + Kontingent-Kachel nur als echte Zahl), Tarif-Cards plakativer + (Icon je Tarif, größerer Preis, Trennlinie, mehr Abstand zum Button), + „Prüfung und Veröffentlichung inklusive" ohne „KI", + „Aktive Buchungen"-Panel entfernt (Info steht im Tarif-Panel), + Verlauf als eigene, klar abgegrenzte Sektion. +- **Dateien**: `resources/views/livewire/customer/bookings.blade.php` + (Neufassung), `app/Http/Controllers/CheckoutController.php` + + `app/Services/Billing/StripeCheckoutService.php` (Billing Portal), + `routes/customer.php`. +- **Build/Test**: Suite 519 passed / 4 skipped, Pint clean; 9 neue Tests + in `BookingsPageTest`, `PanelConsolidationTest` auf neue Seite angepasst. +- **Offene Fragen**: Stripe Tax + Produkt-Sync vor Relaunch im Live-Mode + wiederholen. +- **Nächster Schritt**: 9G Tageslimit (`plans.daily_limit` beim + Veröffentlichen), dann 9H Einzel-PM-Abo-Brücke, 9I Launch-Credits. + +## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅ + +- **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise, + Test-Mode), Webhook-Verarbeitung (STR-Spiegelung + Einmalkauf-Erfüllung; + Endpoint `pressekonto.com/stripe/webhook` registriert, Secret gesetzt), + Checkout-Flows als Backend (`me.checkout.subscription`, + `me.checkout.single-pm`; Stripe Tax via `Cashier::calculateTaxes()`), + Slot-Logik vom Stub auf Plan-Kontingent umgestellt: Abo → Tarif-Quote, + danach Einmalkauf-Verbrauch (consumed + PM-Verknüpfung), + **Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz); + Stub-Spalte `users.press_release_quota` entfernt. +- **Dateien**: `app/Http/Controllers/CheckoutController.php`, + `app/Services/Billing/StripeCheckoutService.php`, + `app/Listeners/ProcessStripeWebhook.php`, + `app/Console/Commands/SyncStripePlans.php`, `app/Models/User.php`, + `app/Services/PressRelease/PressReleaseService.php`, + `routes/customer.php`, `config/billing.php`, Buchungs-Seite (Rückmeldung), + Submit-Modal/Views (Kontingent-Anzeige). +- **Build/Test**: Suite 510 passed / 4 skipped, Pint clean; Stripe-Sync + live gegen Test-Mode gelaufen (Einzel-PM: `STRIPE_PRICE_SINGLE_PM` in .env). +- **Offene Fragen**: Stripe Tax im Dashboard aktivieren (Ursprungsadresse), + sonst schlägt der Checkout fehl; Live-Mode-Sync vor Relaunch. +- **Nächster Schritt**: 9F Tarif-Seite/Buchungs-UI an die Checkout-Routen + anbinden (Mock ablösen), danach 9G Tageslimit. + +## 2026-06-12 · Phase 9D · Tarif-Datenmodell, Rechnungskreise & USt ✅ + +Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`. +Plan: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md` (9D ✅, 9E in Arbeit). + +**Tarif-Datenmodell** +- Laravel Cashier ^16.5 (freigegeben), `User` ist Billable, + Cashier-Migrationen published + ausgeführt. +- `plans` (4 Tiers, Netto-Preise, Kontingente, Tageslimits, Seeder), + `single_purchases` (Einzel-PM, Extra-PM, Boost, PDF-Nachweis). +- `hasActiveBooking()` prüft hybrid: Cashier-Abo ∨ bezahlter + Einmalkauf ∨ aktive Legacy-Vereinbarung. + +**Hybride Rechnungskreise (Entscheidung 12.06.)** +- `InvoiceNumberGenerator`: atomare fortlaufende Nummern, STR- (Stripe) + / MAN- (manuell); Alt-Archiv `legacy_invoices` bleibt unverändert. +- MAN-Fälligkeitslauf `billing:generate-manual-invoices` (täglich + 04:30): Periodenende → Rechnung mit Adress-Snapshot → Periode weiter. + +**Legacy-Migration (P6.6, Runbook entsperrt)** +- `legacy:grandfather-subscriptions`: aktive jährliche Vereinbarungen + aus dem Rechnungsarchiv (22 im Test-Snapshot, 4 sofort fällig) als + `grandfathered` nach `user_payment_options` — Replay-fähig für den + Lauf kurz vor Relaunch. + +**USt (Einwand 12.06.: alle neuen Preise netto; Legacy war brutto)** +- `VatResolver`: DE immer Steuer, EU nur mit USt-ID befreit (Reverse + Charge + Pflichthinweis `invoices.tax_note`), Drittland befreit. +- `vat_id` an Rechnungsadresse + Rechnungs-Snapshot; Netto-Ableitung + der Legacy-Beträge (199 € brutto → 167,23 € netto + 31,77 € USt — + Brutto bleibt für DE-Bestandskunden identisch). +- Offen: VIES-Validierung, PDF-Layout, Steuerberater-Abnahme. + +**Verifikation**: Suite 490 passed / 4 skipped (39 neue Billing-Tests +über 4 Commits). Pint clean. Dry-Runs gegen Echtdaten validiert. + +--- + +## 2026-06-12 · Phase 9 · Veröffentlichungs-Flow Block 1 (9A–9C) ✅ + +Plan-Doc: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`. Grundlage: +`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md` +(+ Entscheidung 12.06.: Gelb geht direkt live). + +Vorab Block 0: Repo aufgeräumt (3 Artefakt-Dateien im Root entfernt), +der unkommittierte Stand vom 29.05.–11.06. in drei Commits gesichert +(Vite/Multi-Domain-Infra, User-Panel/KI-Pipeline, Doku-Sync). + +**9A — Gelb-Routing Direkt-Live** +- `routeByClassification()`: Gelb durchläuft denselben Auto-Publish-Pfad + wie Grün (`autoPublishApproved()`); nur Rot wird abgelehnt. +- Scheduler publiziert fällige gelbe + grüne PMs; unklassifizierte + bleiben als Fallback in der manuellen Queue. + +**9B — Slot-Verbrauch bei Veröffentlichung** +- Increment aus `submitForReview()` entfernt; `publish()` und + `changeStatusFromAdmin()` zählen idempotent beim ersten + `published`-Übergang (Prüfung über Status-Logs). Rot kostet nichts. +- Submit-Guard: Einreichen erfordert freien Slot + (`QuotaExceededException`, API 422). + +**9C — Submit-Gate + Funnel-Fix** +- `User::hasActiveBooking()`-Stub hinter `config/billing.php` + (`enforce_booking`, Default aus) — Tarif-Modul ersetzt nur den Rumpf. +- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis mit + CTA zur Buchungs-Seite; Server-Guard (`BookingRequiredException`), + API antwortet 402. +- **Befund + Fix**: Customer-Create legte PMs bei „Zur Prüfung senden" + direkt mit Status `review` an — vorbei an Blacklist, Quota, KI und + Status-Log. Jetzt: immer Draft anlegen, dann `submitForReview()`. + +**Verifikation**: Suite 451 passed / 4 skipped (9 neue Tests: +Quota-Semantik, Gelb-Routing, Gate via Service/API/Modal). Pint clean. + +Nächster Schritt: Review-Stopp, dann Block 2 (9D–9J: Tarif-Datenmodell, +Stripe/Cashier — Dependency-Freigabe nötig, Tarif-UI, Tageslimit, +Einzel-PM, Launch-Credits). + +--- + +## 2026-05-29 · Phase 8 · User-Panel-Konsolidierung abgeschlossen (8F–8K) ✅ + +Abschluss von Phase 8. Die erste Hälfte (8A–8E: Show-Page-Lücken, +Listen-Indikatoren, Pressekontakt-Warnung, Firmen-Liste auf Mockup-Niveau) +war bereits im Commit „Optimierung der User und Admin Panels" enthalten, +aber undokumentiert. Heute der zweite Block plus Doku-Sync. + +Roadmap-Doc: `20-PHASE-8-USER-PANEL.md`. Plan: `docs/PHASE-8-USER-PANEL-PLAN.md`. + +**8D — Doku-Sync**: `docs/user-admin/checkliste-user-backend.md`, +`docs/STATUS-ABGLEICH-USER-PANEL.md` und `Admin-User.md` auf den +echten IST-Stand gezogen (8A–8E waren als „offen" markiert, sind +aber umgesetzt). + +**8F — SVG-Titelbild-Platzhalter** +- 9 Varianten (3 Muster × 3 Hub-Farben) in + `public/images/press-release-placeholders/`. +- `App\Enums\PressReleasePlaceholder` (Default, Seed-deterministisch). +- `` + Picker-Modal + (`components.press-release-placeholder-picker`, dispatcht + `placeholder-selected`). + +**8G — Titelbild-Schema + Cover-Resolver** +- Migration `placeholder_variant` auf `press_releases` (nullable). +- Model-`creating`-Hook setzt deterministisch eine Variante. +- `App\Services\PressRelease\PressReleaseCoverImage` + (`coverUrl`/`coverIsPlaceholder`). +- Hero-Bild in Customer-/Admin-Show; Vorschau + Picker in Create/Edit. + +**8H — Bild-Upload mit Lizenz-Pflichtfeldern** +- Migration: `author`, `license_type`, `license_url`, + `persons_consent`, `rights_confirmed_at` auf `press_release_images`. +- `App\Enums\ImageLicenseType` (CC/kommerziell erzwingen Lizenz-URL). +- Image-Manager: Urheber (Pflicht), Lizenztyp (Pflicht), Lizenz-URL + (bedingt), Personen-Einwilligung, Rechte-Bestätigung (Pflicht). +- Abweichung: Upload-Control bleibt `flux:input type=file` statt + `flux:file-upload` (Stabilität). Lizenzerfassung vollständig. + +**8J — Quota-Stub (vor 8I, damit Modal darauf aufsetzt)** +- Migration: `users.press_release_quota` (3), + `..._used_this_month` (0). +- `User::pressReleaseQuotaRemaining()`, + Decrement in `PressReleaseService::submitForReview()`. +- Command `press-releases:reset-monthly-quota` + Scheduler + (`monthlyOn(1, '00:05')`). + +**8I — Veröffentlichungs-Modal (Customer-Show)** +- „Zur Prüfung einreichen" öffnet jetzt ein FluxUI-Modal statt + `wire:confirm`: Rechtshinweis (Platzhalter, anwaltlich zu prüfen), + Kontingent-Badge, 3 Bestätigungs-Checkboxen (Submit via Alpine + disabled bis alle gesetzt) → ruft das bestehende `submitForReview()`. + +**8K — Abschluss** +- Neue Tests: `PressReleasePlaceholderTest` (8), + `PressReleaseImageLicenseTest` (3), `PressReleaseQuotaTest` (3), + `PressReleasePublishModalPhase8iTest` (2). +- Suite: **375 passed, 4 skipped**. Pint clean. + `npm run build:portal` clean. + +--- + +## 2026-05-29 · Wartung · Test-Regression-Fix + Phase-7-Doku nachgezogen + +Review der Gesamt-Umsetzung. Zwei Befunde behoben: + +### Fix — `ProfileUpdateTest > profile page is displayed` + +Seit dem Customer-Portal-Umbau ist `/settings/profile` ein +**Redirect** auf `/admin/me/profile` (route `me.profile`), die +Profil-Pflege liegt jetzt in der Volt-Komponente +`customer.profile`. Der Starter-Kit-Test machte aber weiterhin +`GET /settings/profile`→`assertOk()` und lief deshalb auf 302 +statt 200 (undokumentierte Regression aus dem Commit +„Optimierung der User und Admin Panels", 2026-05-22). + +Test umgestellt auf `assertRedirect('/admin/me/profile')`. Das +Rendern der Zielseite ist bereits durch +`CustomerProfileSecurityTest` (Volt-Komponententest) abgedeckt, +also keine Doppelung. Die übrigen 4 Tests der Datei nutzen +weiterhin `Volt::test('settings.profile')` / `delete-user-form` +(Komponenten existieren und sind funktional). + +### Doku-Sync + +Phase 7 war im Code vollständig umgesetzt (siehe Eintrag unten), +aber im Log nicht erfasst und in den Status-Tabellen +widersprüchlich (`19-PHASE-7` „✅", `03-WEITERE-PHASEN` „🟡 in +Planung", `README` noch auf Stand Phase 2). Nachgezogen: +- Phase-7-Eintrag in diesem Log ergänzt. +- `README.md` Status-Tabelle auf Phase 0–7 aktualisiert. +- `03-WEITERE-PHASEN.md` Phase 7 von „🟡 In Planung" auf + „✅ abgeschlossen" + Gesamt-Status-Tabelle ergänzt. + +**Validierung**: +- `php artisan test --compact` → 359 passed, 3 skipped, + 1 failed (weiterhin nur der pre-existing `ApiDocumentationTest`, + fehlende `docs/api/v1.yml`) +- `vendor/bin/pint tests/Feature/Settings/ProfileUpdateTest.php` + → fixed (EOF-Blankline) +- `npm run build:portal` → grün (436.51 KB CSS / 58.95 KB gzip) + +--- + +## 2026-05-22 · Phase 7 · Press-Release-Form-Refactor ✅ (retroaktiv dokumentiert) + +> Großes Modul-Refactor außerhalb der ursprünglichen hub-flux- +> Roadmap (0–6). Vorlage: +> `dev/frontend/tailwind_v3/User Neue Mitteilung presseportale.html`. +> Plan-Doc: `19-PHASE-7-PRESS-RELEASE-FORM.md`. +> Dieser Eintrag wurde am 2026-05-29 nachgetragen — die Arbeit +> selbst entstand am 21./22.05. (Commit „Optimierung der User und +> Admin Panels"). + +**7A — Migrations + Models** +- `add_phase7_fields_to_press_releases` (subtitle, + boilerplate_override, scheduled_at, embargo_at — alle nullable) +- `create_press_release_attachments_table` (analog + press_release_images, mit sort_order, soft-deletes) +- `add_boilerplate_to_companies` (companies.boilerplate) +- Models + Factory + Relationen + Casts. + +**7B — Editor + Sanitizer** +- `composer require mews/purifier ^3.4` (approved) +- `App\Services\PressRelease\PressReleaseHtmlSanitizer` + (Allowlist p/br/h2/h3/strong/em/u/ul/ol/li/blockquote/a) +- `` → `` mit reduzierter Toolbar. +- Test: `PressReleaseHtmlSanitizerTest`. + +**7C/7D — Customer Create + Edit Form (UI)** +- 2-Spalter mit sticky Settings-Sidebar (Status & Absenden, + Portal read-only-Badge, Pressekontakt-Single-Select, + Themen-Tags, Veröffentlichung, SEO). +- Linke Spalte: Firma-Selector, Titel/Untertitel mit + Counter-Pillen (`.pr-meter`), `flux:editor`, Medien, + Anhänge, Boilerplate-Box mit Override-Toggle. +- Hub-Form-Bausteine in `hub-components.css` ergänzt + (`.pr-form-label`, `.pr-meter`, `.pr-bald-badge`, + `.pr-ai-hint`, `.pr-check-row`, `.pr-boiler`, `.pr-tag-chip`, + `.pr-pub-opt` …). +- Live-Re-Validation (`updated()` re-validiert Felder mit + bestehendem Error) + Sammel-Toast bei Validierungsfehler. +- Neues JS-Asset `portal-form-hooks` (Build). +- Tests: `CustomerPressReleaseCreatePhase7Test` (8), + `CustomerPressReleaseEditPhase7Test` (9). + +**7E — Anhänge-Manager** +- `App\Services\PressRelease\PressReleaseAttachmentStorage` +- Komponente + `livewire/components/press-release-attachments-manager.blade.php` + (upload/remove/reorder, PDF/DOCX/XLSX/PPTX, Tile-Layout). + +**7F — Scheduling + Embargo** +- UI: Radio „Geplanter Termin" + `datetime-local`, + Embargo-Switch + Date-Picker. +- Validation: scheduled_at min. 5 Min Zukunft, embargo_at + Zukunft (nur wenn Toggle aktiv). +- `PressReleaseService::publish()` → `resolvePublishedAt()` + (published_at > scheduled_at > embargo_at-Verschiebung > now). +- Command `press-releases:publish-scheduled` + (`App\Console\Commands\PublishScheduledPressReleases`, + `--dry-run`, `--limit=N`) + Scheduler-Eintrag in + `routes/console.php` (`everyFiveMinutes`, `withoutOverlapping`, + `runInBackground`). +- Tests: `PressReleaseSchedulingTest` (11), + `CustomerPressReleaseSchedulingFormTest` (5). + +**Effekt auf die Suite**: von dokumentierten ~231 auf ~360 Tests +gewachsen. Admin-Create/Edit ziehen das Layout vorerst NUR +optisch mit; Scheduling/Embargo-UI bleibt Customer-seitig +(laut Plan-Doc Out-of-Scope für Admin in Phase 7). + +--- + ## 2026-05-20 · Phase 6 · Auth-Cleanup ✅ Mit Phase 6 ist die hub-flux-Roadmap (Phase 0–6) **vollständig diff --git a/dev/frontend/hub-flux/README.md b/dev/frontend/hub-flux/README.md index 601b1ed..257872a 100644 --- a/dev/frontend/hub-flux/README.md +++ b/dev/frontend/hub-flux/README.md @@ -11,11 +11,16 @@ |-------|--------------|--------| | 0 | Design-Tokens vereinheitlichen | **✅ abgeschlossen** (2026-05-19) | | 1 | Portal-Shell (Sidebar, Layout, Brand-Mark) | **✅ abgeschlossen** (2026-05-19) | -| 2 | Customer-Dashboard auf Mockup-Stil (inkl. Topbar) | 🟡 wartet auf Freigabe | -| 3 | Admin-Dashboard konsistent | ⚪ später | -| 4 | Listen-/Detail-Pages | ⚪ iterativ | -| 5 | Dark Mode konsistent | ⚪ später | -| 6 | Auth-Konsolidierung (optional) | ⚪ optional | +| 2 | Customer-Dashboard auf Mockup-Stil | **✅ abgeschlossen** (in P1, verfeinert in 4J) | +| 3 | Admin-Dashboard konsistent | **✅ abgeschlossen** (in P1, verfeinert in 4J) | +| 4 | Listen-/Detail-Pages (4A–4J) | **✅ abgeschlossen** (2026-05-20) | +| 5 | Dark Mode konsistent | **✅ abgeschlossen** (2026-05-20) | +| 6 | Auth-Cleanup | **✅ abgeschlossen** (2026-05-20) | +| 7 | Press-Release-Form-Refactor (7A–7F) | **✅ abgeschlossen** (2026-05-22) | + +Die hub-flux-Roadmap (Phase 0–6) ist vollständig abgeschlossen; Phase 7 +(PM-Form-Refactor) als Folge-Initiative ebenfalls. Detail-Plan für Phase 7: +[`19-PHASE-7-PRESS-RELEASE-FORM.md`](./19-PHASE-7-PRESS-RELEASE-FORM.md). → Tagesaktueller Fortschritt: [`PROGRESS.md`](./PROGRESS.md) diff --git a/dev/frontend/responsive/admin:me.png b/dev/frontend/responsive/admin:me.png new file mode 100644 index 0000000..1e0b5fc Binary files /dev/null and b/dev/frontend/responsive/admin:me.png differ diff --git a/dev/frontend/responsive/admin:me:firmen:id.png b/dev/frontend/responsive/admin:me:firmen:id.png new file mode 100644 index 0000000..9e67384 Binary files /dev/null and b/dev/frontend/responsive/admin:me:firmen:id.png differ diff --git a/dev/frontend/responsive/pm-vorschau.png b/dev/frontend/responsive/pm-vorschau.png new file mode 100644 index 0000000..ed40964 Binary files /dev/null and b/dev/frontend/responsive/pm-vorschau.png differ diff --git a/dev/migration 2026/05-DATABASE-MERGE.md b/dev/migration 2026/05-DATABASE-MERGE.md index cd9b1aa..185946a 100644 --- a/dev/migration 2026/05-DATABASE-MERGE.md +++ b/dev/migration 2026/05-DATABASE-MERGE.md @@ -214,12 +214,21 @@ Vor dem Go-Live-Rehearsal muss der Report gegen den aktuellen Produktiv-Snapshot ### 5.6 Payment ⭐ NEU + GRANDFATHERING (D-13) -- **Legacy-PaymentOptions werden nicht übernommen.** Neue Produkte werden vom Auftraggeber definiert und als Stripe-Prices angelegt. -- **Aktive `UserPaymentOption`-Einträge** (Status `active`, `valid_until >= today`) werden als `grandfathered` migriert: - - Neuer Datensatz in `user_payment_options` mit `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until`, `legacy_conditions = {...Snapshot...}`. - - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). - - Scheduler `ExpireGrandfatheredSubscriptions` erzeugt am `grandfathered_until` eine Customer-Benachrichtigung für Umstellung auf neues Produkt. +> **Umgesetzt 2026-06-12** mit präzisierten Kriterien des Auftraggebers: +> Quelle der Aktiv-Erkennung ist **ausschließlich das Rechnungsarchiv** +> (`legacy_invoices`, D-12) — nicht die Legacy-Payment-Tabellen direkt. +> Command: `legacy:grandfather-subscriptions` (idempotent, Replay-fähig). + +- **Legacy-PaymentOptions werden nicht übernommen.** Neue Produkte werden vom Auftraggeber definiert und als Stripe-Prices angelegt. Für die Grandfathered-Vereinbarungen entstehen versteckte Katalog-Platzhalter (`payment_options.article_number = LEGACY-{PE|BP}-{Artikel}`, `is_hidden = true`); die verbindlichen Beträge liegen pro Vereinbarung in `legacy_conditions`. +- **Aktiv-Regel** (aus dem Archiv abgeleitet): jüngste Rechnung pro (Portal, Legacy-`user_payment_option`) mit `pdf_payload.payment_option.type = 'recurring'` und `pdf_payload.user_payment_option.status = 'active'`; `next_due_date` darf höchstens `--grace-months` (Default 12) überfällig sein, sonst gilt die Vereinbarung als stale und bleibt reines Archiv. Einmal-Käufe (`type = single`) werden nie übernommen. +- **Übernahme** als `grandfathered` in `user_payment_options`: + - `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until_date` (nullable), `stripe_subscription_id = null`. + - `current_period_start = service_period_begin_date` der jüngsten Rechnung, `current_period_end = next_due_date` → der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) stellt die nächste Rechnung zum gewohnten (jährlichen) Rhythmus aus. + - **Beträge (Klarstellung 12.06.):** Legacy fakturierte **brutto** (Steuer inkludiert); steuerbefreite Kunden erhielten den Netto-Ausweis (`is_netto`). Die Migration leitet daraus die **Netto-Vertragsbasis** ab (`legacy_conditions.net_cents`; brutto ÷ 1,19 bzw. Netto-Betrag direkt). Die Steuer bestimmt der `VatResolver` pro Rechnung neu: DE immer mit Steuer, EU nur mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit — für deutsche Bestandskunden bleibt der Bruttobetrag unverändert, die Steuer wird künftig sauber ausgewiesen (`invoices.tax_note` bei Befreiung). + - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). Neue manuelle Rechnungen entstehen im **MAN-Rechnungskreis** (`invoices`), nie im Archiv. + - Scheduler `ExpireGrandfatheredSubscriptions` (Customer-Benachrichtigung am `grandfathered_until`) bleibt offen — folgt mit dem Stripe-Billing-Block. - Alle historischen `user_payments` werden als Information ins Archiv geschrieben (analog `legacy_invoices` – optional). +- **Replay (D-18)**: Re-Runs aktualisieren bestehende Einträge anhand `legacy_conditions.legacy_portal` + `legacy_user_payment_option_id` — der Lauf kurz vor dem Relaunch übernimmt damit den dann aktuellen Stand ohne Duplikate. ### 5.7 Coupons diff --git a/dev/migration 2026/08-PROGRESS.md b/dev/migration 2026/08-PROGRESS.md index 419b712..5dfd8ae 100644 --- a/dev/migration 2026/08-PROGRESS.md +++ b/dev/migration 2026/08-PROGRESS.md @@ -4,6 +4,34 @@ Chronologisches Protokoll aller Migrationsschritte. Jede Session / jeder Commit --- +## 2026-06-12 – P6.6 Grandfathering aktiver Legacy-Abos ✅ + +**Phase:** P6 Daten-Migration (D-13, Kriterien vom Auftraggeber präzisiert) +**Status:** ✅ umgesetzt; Rehearsal gegen Produktiv-Snapshot bleibt P6.10. + +- Neuer Command `legacy:grandfather-subscriptions` (`--dry-run`, `--as-of=`, + `--grace-months=12`, `--no-report`; JSON-Report nach + `storage/app/migration/grandfather-subscriptions-*.json`). +- Quelle ist ausschließlich das Rechnungsarchiv `legacy_invoices` (D-12): + jüngste Rechnung pro (Portal, Legacy-UPO) mit `payment_option.type = + recurring` und `user_payment_option.status = active`; `next_due_date` + max. 12 Monate überfällig, sonst stale → bleibt Archiv. +- Übernahme als `grandfathered` in `user_payment_options` mit + `current_period_end = next_due_date` und Beträgen der letzten + Legacy-Rechnung in `legacy_conditions` — der tägliche MAN-Kreis-Lauf + (`billing:generate-manual-invoices`, Phase 9D im Hauptprojekt) + fakturiert ab dann zum gewohnten jährlichen Rhythmus weiter. +- Versteckte Katalog-Platzhalter `LEGACY-{PE|BP}-{Artikel}` in + `payment_options`; Re-Runs aktualisieren statt duplizieren (D-18, + Replay kurz vor Relaunch). +- Dry-Run gegen aktuellen Test-Snapshot: 22 aktive jährliche + Vereinbarungen (49 € bis 1.190 €), davon 4 sofort fällig; 0 stale. +- Tests: `tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php` + (7 Tests, inkl. End-to-End: Migration → MAN-Rechnung mit + Legacy-Beträgen). + +--- + ## 2026-05-04 – P6.5d Legacy-Rechnungen Vollimport + on-demand PDF ✅ **Phase:** P6 Daten-Migration + P4 Customer-Portal diff --git a/dev/migration 2026/12-NAECHSTE-SCHRITTE.md b/dev/migration 2026/12-NAECHSTE-SCHRITTE.md index ae4429b..ad5cac0 100644 --- a/dev/migration 2026/12-NAECHSTE-SCHRITTE.md +++ b/dev/migration 2026/12-NAECHSTE-SCHRITTE.md @@ -107,7 +107,7 @@ Der Kern (Erstellen → Submit → Review → Publish/Reject mit Reason + Audit- | # | Aufgabe | Priorität | Status | |---|---|---|---| | 6.5d | **Legacy-Rechnungen Vollimport**: alle bestehenden Rechnungen aus den Legacy-DBs inkl. Status, Beträgen, Datum, Zahlart, vollständigem Raw-Snapshot und User-Zuordnung importieren; `legacy:archive-invoices` schreibt Import-Report + PDF-Payload; PDF-Erzeugung bleibt DB-basiert/on-demand statt Datei-Migration | 🔴 | ✅ umgesetzt 2026-05-04; Rehearsal gegen Produktiv-Snapshot bleibt P6.10 | -| 6.6 | `legacy:grandfather-subscriptions` (aktive Alt-Abos übernehmen) | 🔴 | ⬜ wartet auf Auftraggeber-Kriterien | +| 6.6 | `legacy:grandfather-subscriptions` (aktive Alt-Abos übernehmen) | 🔴 | ✅ umgesetzt 2026-06-12 (Kriterien vom Auftraggeber: Quelle ist das Rechnungsarchiv — jüngste Rechnung pro Vereinbarung mit `recurring` + `active`; Übernahme als `grandfathered` mit `current_period_end = next_due_date`, MAN-Kreis fakturiert weiter; Replay-fähig, Rehearsal bleibt P6.10) | | 6.10 | **Rehearsal-Lauf** gegen produktiven Snapshot auf Staging | 🔴 | ⬜ | **Wichtig für 6.5d:** `legacy:archive-invoices` importiert jetzt Rechnungsdaten, Billing-Adress-Snapshot und User-Payment-Snapshot in `legacy_invoices.raw_snapshot`/`pdf_payload`, zählt unzugeordnete Legacy-User im Report und lässt diese Rechnungen trotzdem im Archiv. Für Legacy-Rechnungen bleibt die bestehende Logik erhalten: Rechnung liegt als Datenbankdatensatz vor und das PDF wird bei Bedarf auf Knopfdruck aus diesen Daten erzeugt. Neue Stripe-Rechnungen werden separat in P8 geplant. Der finale Nachweis der Vollständigkeit erfolgt weiterhin im Staging-Rehearsal mit aktuellem Produktiv-Snapshot. diff --git a/dev/migration 2026/MIGRATION-STEPS.md b/dev/migration 2026/MIGRATION-STEPS.md index ce96c6b..3d52b66 100644 --- a/dev/migration 2026/MIGRATION-STEPS.md +++ b/dev/migration 2026/MIGRATION-STEPS.md @@ -1,12 +1,13 @@ # Migration Steps – aktuelles Runbook -Stand: 2026-05-04. Dieses Kurz-Runbook spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`. +Stand: 2026-06-12. Dieses Kurz-Runbook spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`. ## Dry-Run ```bash php artisan legacy:import --source=all --dry-run php artisan legacy:archive-invoices --dry-run +php artisan legacy:grandfather-subscriptions --dry-run php artisan legacy:verify --no-report php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration --dry-run @@ -27,10 +28,22 @@ php artisan legacy:import --source=presseecho --step=press-releases --force php artisan legacy:import --source=businessportal24 --step=press-releases --force php artisan legacy:import --step=link-associations --force php artisan legacy:archive-invoices +php artisan legacy:grandfather-subscriptions php artisan legacy:fix-timestamps php artisan legacy:verify ``` +Hinweis: `legacy:grandfather-subscriptions` läuft **nach** `legacy:archive-invoices`, +weil es die aktiven, jährlich wiederkehrenden Zahlungsvereinbarungen aus dem +Rechnungsarchiv ableitet (jüngste Rechnung pro Vereinbarung mit +`payment_option.type = recurring` und `user_payment_option.status = active`) +und als `grandfathered` in `user_payment_options` schreibt. Die nächste +Rechnung stellt danach der tägliche MAN-Kreis-Lauf +(`billing:generate-manual-invoices`) zum gewohnten Rhythmus aus. Re-Runs +aktualisieren bestehende Einträge (Replay-fähig für den Lauf kurz vor dem +Relaunch). Optionen: `--dry-run`, `--as-of=`, `--grace-months=12` (älter +überfällige Vereinbarungen gelten als stale und bleiben reines Archiv). + Hinweis: Der Schritt `--step=users` importiert nicht nur `sf_guard_user`, sondern auch die direkt verknüpften Daten aus `sf_guard_user_profile` in die neue Tabelle `profiles`. ## Alternativer Komplettlauf @@ -38,6 +51,7 @@ Hinweis: Der Schritt `--step=users` importiert nicht nur `sf_guard_user`, sonder ```bash php artisan legacy:import --source=all --force php artisan legacy:archive-invoices +php artisan legacy:grandfather-subscriptions php artisan legacy:fix-timestamps php artisan legacy:verify php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration @@ -45,7 +59,6 @@ php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migrati ## Noch nicht im Runbook finalisiert -- `legacy:grandfather-subscriptions`: noch nicht implementiert bzw. blockiert durch Kriterien vom Auftraggeber. - Medien-/Bilddateien-Transfer: Scope und finaler Command noch offen. - Staging-Rehearsal mit aktuellem Produktiv-Snapshot bleibt Pflicht vor Go-Live. diff --git a/dev:web b/dev:web deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose.yml b/docker-compose.yml index c89d938..ab55f23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,11 +55,11 @@ services: - "traefik.http.routers.businessportal.tls=true" - "traefik.http.routers.businessportal.service=pressekonto-service-prc" - # Asset Domain für Vite-Server Portal (Port 5177) - - "traefik.http.routers.assets-portal.rule=Host(`assets.pressekonto.test`)" - - "traefik.http.routers.assets-portal.entrypoints=websecure" - - "traefik.http.routers.assets-portal.tls=true" - - "traefik.http.routers.assets-portal.service=assets-portal-service-prc" + # Asset Domain für Vite-Server Portal/Admin (Port 5177) + - "traefik.http.routers.assets-pressekonto.rule=Host(`assets.pressekonto.test`)" + - "traefik.http.routers.assets-pressekonto.entrypoints=websecure" + - "traefik.http.routers.assets-pressekonto.tls=true" + - "traefik.http.routers.assets-pressekonto.service=assets-portal-service-prc" # Asset Domain für Vite-Server Presseecho - "traefik.http.routers.assets-presseecho.rule=Host(`assets.presseecho.test`)" diff --git a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md new file mode 100644 index 0000000..7c1d0c6 --- /dev/null +++ b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md @@ -0,0 +1,197 @@ +# Decision-Update · Preisstruktur & Veröffentlichungs-Flow (Launch) + +**Version:** Juni 2026 **Datum:** 11.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Launch-Preisstruktur, PM-Kontingente, Launch-Credit-Umfang, Veröffentlichungs-Flow. Ersetzt die betroffenen Stellen früherer Tarif-Festlegungen (insb. `konzept/Konzept-Update 1` §8–10 und `user-admin/Presseportal – Konzept für Relaunch` §8–10). + +> **IST-Stand 11.06.2026**: Reines Entscheidungs-Dokument, noch nicht +> umgesetzt. Im Code existieren bisher nur der Quota-Stub +> (`users.press_release_quota`, zählt aktuell beim **Einreichen** statt bei +> der Veröffentlichung) und die KI-Klassifikation (Rot/Gelb/Grün, siehe +> `user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`). +> Zahlung/Tarife, Submit-Gate hinter Buchung, Slot-Verbrauch bei +> Veröffentlichung, Tageslimit, Einzel-PM und die drei Credit-Posten sind +> offen — siehe Abgleich in `STATUS-ABGLEICH-USER-PANEL.md` §3.5. + +--- + +## 1. Kontext + +Dieses Update bündelt die in der Abstimmung getroffenen Entscheidungen zur Preisstruktur und zum Veröffentlichungs-Flow für den Relaunch. Leitlinie blieb durchgängig die Anti-Zombie-Positionierung: keine versteckten Gebühren, keine künstliche Verknappung, kein Bezahlen für Leistungen, die nicht erbracht wurden. Mehrere ältere Festlegungen wurden bewusst überschrieben (siehe Abschnitt 5). + +--- + +## 2. Finalisierte Tarifstruktur (Launch) + +|Tier|Monatlich|Jährlich|PMs/Monat|Pro PM| +|---|---|---|---|---| +|**Starter**|29 €|290 €|3|9,67 €| +|**Business**|49 €|490 €|10|4,90 €| +|**Pro**|99 €|990 €|25|3,96 €| +|**Agency**|199 €|1.990 €|60|3,32 €| + +**Einzel-PM:** 19 € einmalig – geführt als **separater No-Abo-Block** neben dem Tarif-Raster, nicht als linke/billigste Spalte. Kommunikation über das No-Commitment-Argument („Einmal veröffentlichen, kein Abo, keine Kündigung"), nicht über den Preis. + +### 2.1 Klarstellung Preise & Steuern (Einwand 12.06.2026) + +**Alle neuen Preise sind Netto-Preise.** Die Umsatzsteuer wird zur +Rechnungsstellung anhand der Rechnungsadresse bestimmt und sauber +ausgewiesen: + +- **Deutschland** → grundsätzlich immer mit Steuer (aktuell 19 %). +- **EU-Ausland** → nur mit gültiger USt-ID steuerbefreit (Reverse Charge), + sonst mit Steuer. +- **Drittländer** → grundsätzlich steuerbefreit. + +Zum Vergleich Legacy: Dort waren die Beträge **brutto** (z. B. 199 € inkl. +Steuer); steuerbefreite Kunden erhielten den Netto-Ausweis (167,23 €). +Grandfathered-Vereinbarungen werden deshalb auf die Netto-Basis der letzten +Legacy-Rechnung umgerechnet — für deutsche Bestandskunden bleibt der +Bruttobetrag damit unverändert, die Steuer wird künftig nur sauber +ausgewiesen. + +**Enterprise:** sichtbar, aber als **dezenter Sales-Hinweis unterhalb der Tabelle** („Größere Mengen oder mehrere Marken? → Kontakt"). Keine eigene Preisspalte, individuelles Pricing. + +--- + +## 3. Mechaniken & Regeln + +### 3.1 Jahrespreis-Kommunikation + +Die Jahrespreise entsprechen exakt **10 Monatsbeiträgen** → kommuniziert als **„2 Monate gratis"** statt als Prozent-Rabatt. Numerisch identisch zu den Bestandszahlen, nur die Darstellung ändert sich. Der konkrete Hebel zieht psychologisch stärker und passt zur Ehrlichkeits-Linie. + +### 3.2 PM-Kontingent: Verbrauch + +**Der PM-Slot zählt ausschließlich bei Veröffentlichung runter, nicht bei der Prüfung.** + +- Rot (abgelehnt) → **kein** Slot verbraucht +- Gelb/Grün (veröffentlicht) → Slot zählt runter + +Begründung: Wer nichts veröffentlicht bekommt, zahlt keinen Slot. Schützt insbesondere ehrliche Nutzer, deren PM erst nach Nachbesserung durchgeht (relevant ab Phase 2). + +### 3.3 Tageslimit (Flut-Schutz) + +Schützt die redaktionelle Glaubwürdigkeit des Portals gegen Dumping, nicht den Umsatz. Greift realistisch nur in den oberen Tiers. + +|Tier|PMs/Monat|Max./Tag| +|---|---|---| +|Starter|3|–| +|Business|10|2| +|Pro|25|3| +|Agency|60|5| + +Das Tageslimit gilt **auch für nachgekaufte Extra-PMs** – sonst würde Extra-PM zum „Spam freikaufen". Höherer Tagesdurchsatz = Enterprise-Fall. + +### 3.4 Einzel → Abo-Brücke + +Wer als Einzel-Käufer innerhalb von 30 Tagen ein Abo abschließt, bekommt die 19 € auf den ersten Monat angerechnet. Schützt das Einmal-Segment und bietet einen sauberen Upgrade-Pfad. + +--- + +## 4. Launch-Credit-System (klein gehalten) + +Zum Launch greifen genau drei Credit-Posten – alle ohne KI-Abhängigkeit, alle mit echtem Nutzen ab Tag 1: + +|Posten|Status|Mechanik| +|---|---|---| +|**Extra-PM**|✅ Launch|Monatskontingent voll → einzelne PM nachkaufbar (faire Alternative zum Zwangs-Upgrade)| +|**Boost / Platzierung**|✅ Launch|Nur für **grüne** PMs, nachträglich kaufbar| +|**Veröffentlichungsnachweis (PDF)**|✅ Launch|Kleiner Mitnahme-Posten, PR-Beleg fürs Reporting| + +**Credit-Anker:** 1 Credit = 1 € (Listenpreis), Volumenrabatt über Pakete. + +**Verlinkung:** Links zur Kundenseite sind Standard-Bestandteil jeder PM, systemseitig als `sponsored`/`nofollow` ausgezeichnet. Hervorhebung der Linkdarstellung ist als Produkt-Feature möglich. **Tabu:** Verkauf von Dofollow-Backlinks und kundenseitige `rel`-Auswahl. Details siehe Decision-Update „Verlinkung & Backlinks". + +--- + +## 5. Veröffentlichungs-Flow (Launch) + +``` +Schreiben & „Speichern" → buchen → „Speichern & zur Prüfung einreichen" + (frei) (gegated hinter Buchung) + │ + KI-Prüfung (Red-Flag) + │ + ┌──────────────────────────┼──────────────────────────┐ + ROT GELB GRÜN + abgelehnt veröffentlicht veröffentlicht + kein Slot weg Slot −1 Slot −1 + (Boost nachkaufbar) +``` + +### 5.0 Klarstellung Gelb-Routing (Entscheidung 12.06.2026) + +**Gelb geht zum Launch direkt live — es gibt keine manuelle Prüf-Queue.** +Die Klassifikation kennt nur zwei Ausgänge: + +- **Rot** = Inhalte, die rechtlich oder inhaltlich **nicht veröffentlichbar** + sind → Ablehnung mit Meldung/Begründung an den Autor, kein Slot-Verbrauch. +- **Gelb/Grün** = veröffentlichbar → der Beitrag geht in der ersten Phase + direkt online (sofort bzw. zum geplanten Termin), Slot −1. + +Gelb bleibt als interne Markierung erhalten (z. B. nicht boostbar, Signal +für den Admin), löst aber **keinen** manuellen Review-Schritt aus. Nach dem +Relaunch sind **Vorabprüfungen** geplant (Phase 2, siehe Abschnitt 7), die +Usern die Möglichkeit geben, ihren Beitrag vor der Einreichung zu +korrigieren. + +### 5.1 Zwei-Button-Logik + +- **„Speichern"** – immer frei, auch ohne Buchung. Entwürfe schreiben/ablegen reibungslos, auch _vor_ der Buchung (Conversion-Vorteil: fertige PM senkt Kaufhürde). +- **„Speichern & zur Prüfung einreichen"** – sichtbar, aber gegated. Ohne aktive Buchung öffnet das Modal einen Buchungs-Hinweis statt des Prüf-Flows. Der Button konvertiert, er verschwindet nicht. + +Begründung für den Gate: Die Prüfung ist die erste kostenpflichtige KI-Ressource. Kein gebuchtes Produkt → kein Ressourcenverbrauch. + +### 5.2 Kein Re-Check zum Launch + +Einreichen ist zum Launch eine **Einbahnstraße**: Die PM geht durch (gelb/grün → live) oder wird abgelehnt (rot). Es gibt **keinen** „nachbessern und erneut prüfen"-Loop, weil Redigieren/Vorab-Prüfung erst Phase 2 sind. + +Konsequenz: Pro PM gibt es genau **eine** Prüfung, untrennbar an die Veröffentlichung gekoppelt. Eine eingereichte PM = eine Prüfung = (bei gelb/grün) eine Veröffentlichung. Damit existiert zum Launch **kein Prüf-Abuse-Vektor** → der komplette Prüfzähler-Mechanismus ist zum Launch nicht nötig. + +### 5.3 Keine Gratis-Test-Prüfung + +Harter Gate zum Launch. Eine kostenlose Vorab-Prüfung würde den Flow nur verkomplizieren (wenn eine PM durchgeht, wird sie ohnehin veröffentlicht) und einen Abuse-Vektor öffnen (Wegwerf-Accounts). Eine kontrollierte Test-Prüfung (z. B. pro verifizierter Domain) bleibt als spätere Option offen. + +--- + +## 6. Überschriebene Entscheidungen + +|Bereich|Alt|Neu|Grund| +|---|---|---|---| +|**PM-Kontingent Pro**|60/Monat|**25/Monat**|60 unrealistisch hoch → Overage-System griff nie, Kontingent wirkte faktisch „unbegrenzt"| +|**PM-Kontingent Agency**|150/Monat|**60/Monat**|dito; Kontingente jetzt an realistischer PR-Frequenz kalibriert| +|**Jahrespreis-Kommunikation**|„ca. 17 % Rabatt"|**„2 Monate gratis"**|gleicher Preis, stärkerer psychologischer Hebel, klarer| +|**Bonus-Credits in Tarif-Tabelle**|12/30/60/120 als Tarif-Argument|**entfernt** (→ Phase 2 als Prüf-Kontingent)|bewarb zum Launch eine Leistung ohne Verbrauchsmöglichkeit| + +--- + +## 7. Auf Phase 2 verschoben + +|Punkt|Warum verschoben| +|---|---| +|**Vorab-KI-Prüfung**|erzeugt erst die Situation „prüfen ohne (noch) zu veröffentlichen"| +|**Redigieren / Nachbearbeiten**|setzt Re-Check-Loop voraus| +|**Prüfzähler** (freie Prüfungen/Monat, z. B. 12/30/60/120, eigener Zähler)|erst mit Re-Check relevant; deckelt dann „prüfen ohne veröffentlichen"| +|**Credit-Overflow für Prüfungen**|Prüfzähler leer → weitere Prüfungen ziehen aus echter Credit-Wallet| +|**Klon-/Abuse-Schutz über Account-Monatslimit**|aggregiertes Limit pro Account statt Klon-Erkennung; greift erst bei Re-Check| +|**Score-Feinstufung für Boost**|„nur Geprüft/Hochwertig boostbar" – setzt vollen Content-Score voraus| +|**Tier-gestaffelte Prüf-Versuche**|als „BALD" markiert; zum Launch flach| + +**Designprinzip für Phase 2 festgehalten:** Eigener **Prüf-Zähler** (getrennt von der Credit-Wallet), damit „Prüfungen inklusive" ein sauberes Versprechen bleibt und Prüf-Budget nicht versehentlich für Boost/PDF verbraucht wird. Abuse-Schutz über aggregiertes Account-Monatslimit + Prüf-Tageslimit – nicht über Klon-Erkennung. + +--- + +## 8. Offene Stellschrauben (vor Phase-2-Bau zu entscheiden) + +- **Boost-Nachkaufpreis** relativ zum inkludierten PM-Preis – klar darüber (treibt Upgrade) oder nur leicht darüber (bequemer, schwächerer Upgrade-Sog). +- **Höhe des Prüf-Kontingents** je Tier final bestätigen, sobald Vorab-Prüfung gebaut wird (Ausgangsvorschlag 12/30/60/120). +- **Credit-Paketliste** auf Konsistenz prüfen (vom Nutzer angekündigt, separat einzubringen). + +--- + +## 9. Anti-Zombie-Check (dieser Stand) + +- ✅ Keine versteckten Gebühren – Extra-PM und Boost sind sichtbare, optionale Zukäufe +- ✅ Keine künstliche Verknappung – Kontingente decken den Normalfall bequem; Limits greifen nur bei echtem Power-/Abuse-Verhalten +- ✅ Kein Bezahlen für nicht erbrachte Leistung – rot abgelehnte PM verbraucht keinen Slot +- ✅ Tageslimit als Qualitätsschutz fürs Portal begründet, nicht als Verkaufstrick +- ✅ Kein verkaufter Dofollow-Backlink +- ✅ Free schreiben, Gate erst beim Einreichen – Friktion an der richtigen Stelle \ No newline at end of file diff --git a/docs/Echte öffentliche Unterseiten.md b/docs/Echte öffentliche Unterseiten.md new file mode 100644 index 0000000..06aff05 --- /dev/null +++ b/docs/Echte öffentliche Unterseiten.md @@ -0,0 +1,170 @@ +> **Stand der Doku**: 21.05.2026 — diese Liste beschreibt den Zielzustand +> der oeffentlichen Strecke. Welcher Punkt bereits umgesetzt ist, ist +> jeweils mit einer kurzen IST-Notiz markiert. + +Das sind die Seiten, die eigene URLs brauchen, weil sie verlinkbar sein müssen, SEO-Wert haben oder direkt von extern angesteuert werden. + +#### Inhalts-Seiten (Lese-Erfahrung) + +**1. Pressemitteilungs-Detailseite** – `/p/[slug]` oder `/pressemitteilung/[id]` Die wichtigste Seite überhaupt. Jede einzelne PM bekommt eine eigene Seite. Hier landen 90% des Traffics aus Google, Newsletter und Social Shares. +_IST 21.05.2026_: umgesetzt als `resources/views/web/release-detail.blade.php` (Route `release.detail`, URL `/release/{slug}`). Das URL-Schema weicht vom Plan ab, ist aber konsistent über alle Themen. + +**2. Branchen-Übersichten** – `/branche/[slug]` Zum Beispiel `/branche/energie-klima`, `/branche/finanzen`. Aggregierte Sicht auf alle PMs einer Branche, mit Sub-Filtern. Das sind deine SEO-Goldgruben (jede Branche eine ranking-fähige Landing Page). +_IST 21.05.2026_: umgesetzt (`web/kategorie.blade.php`, `web/kategorien.blade.php`). + +**3. Regionen-Übersichten** – `/region/[slug]` `/region/deutschland`, `/region/bayern`, `/region/oesterreich`. Analog zu Branchen, regional gefiltert. +_IST 21.05.2026_: noch nicht umgesetzt. + +**4. Newsroom-Seite eines Unternehmens** – `/newsroom/[slug]` Markenseite eines Premium-Publishers mit eigener URL, Logo, allen PMs des Unternehmens. Ist gleichzeitig Verkaufsargument für Pro-/Agency-Tarif und SEO-Vorteil für die Unternehmen. +_IST 21.05.2026_: Layout vorhanden (`web/newsrooms.blade.php`), Daten-Anbindung pro Firma noch offen. + +**5. Such-Ergebnisseite** – `/suche?q=...` Volltextsuche mit Filtern (Erweiterte Suche schreibt in URL-Parameter, dadurch teilbar/bookmarkbar). +_IST 21.05.2026_: Layout vorhanden (`web/suche.blade.php`), Volltextsuche noch nicht aktiv. + +**6. Tag-/Themen-Seite** – `/thema/[slug]` _(optional, später)_ Nicht im ersten Release zwingend, aber sehr SEO-wirksam für aktuelle Themen ("Künstliche Intelligenz", "Lieferkettengesetz", "Energiekrise"). Würde ich datengetrieben aus den meistverwendeten Tags generieren lassen. +_IST 21.05.2026_: nicht umgesetzt (bewusst spaeter). + +#### Service-/Vertriebs-Seiten + +**7. Pressemitteilung einreichen / Veröffentlichen** – `/veroeffentlichen` Die Conversion-Landingpage für neue Publisher. Erklärt Mehrwert, zeigt Tarife, Editor-Vorschau. Dahinter der eigentliche Editor (im User-Bereich). +_IST 21.05.2026_: Landing-Seite vorhanden (`web/veroeffentlichen.blade.php`). Editor-Strecke im User-Bereich ist umgesetzt (siehe Phase 7). + +**8. Tarife & Preise** – `/preise` _(oder als Modal aus mehreren Stellen aufrufbar)_ Da Tarife auch im Modal aus dem CTA aufgerufen werden, ist die Frage: brauchen wir die Seite? Antwort ja, weil SEO ("Pressemitteilung veröffentlichen Preise" ist eine wichtige Suche) und weil sie verlinkbar sein muss aus AGB, Footer, Mediadaten. +_IST 21.05.2026_: Layout vorhanden (`web/preise.blade.php`), echte Tarife noch nicht hinterlegt (Tarif-Modul siehe `Presseportal – Konzept für Relaunch.md` Abschnitt 8). + +**9. Mediadaten / Werbung** – `/mediadaten` oder `/werben` Für Mediaplaner und potentielle Werbekunden: alle buchbaren Slot-Typen (Top-Slot, Highlights, Newsletter, Branchen-Sponsoring), Reichweiten-Daten, Preise, Booking-Kontakt. Pflicht-Seite für jede Plattform mit Anzeigeninventar. +_IST 21.05.2026_: nicht umgesetzt. + +**10. Newsletter-Anmeldung als eigene Seite** – `/newsletter` Auch wenn Newsletter im Footer und in einer Sektion auf der Startseite eingebettet ist, brauchst du eine eigene Seite für direkte Anmelde-Links (aus E-Mails, Social, Werbekampagnen). +_IST 21.05.2026_: Layout in den Themes vorhanden, eigene Anmelde-Seite noch nicht. + +#### Vertrauens- / Editorial-Seiten + +**11. Über uns** – `/ueber-uns` Plattform-Geschichte, Team, redaktionelle Haltung. Kurz und persönlich, kein Marketing-Geschwurbel. +_IST 21.05.2026_: Layout vorhanden (`web/ueber-uns.blade.php`, `web/team.blade.php`). + +**12. Redaktion / Redaktionsrichtlinien** – `/redaktion` Wichtige Vertrauensseite: Wer prüft die Inhalte? Wie funktioniert der Content-Score? Was ist der Unterschied zu redaktionell geprüften Anzeigen? Diese Seite differenziert dich von Spam-Portalen. +_IST 21.05.2026_: noch nicht umgesetzt. + +**13. Kontakt** – `/kontakt` Klassisch, mit Funktions-E-Mails (presse@, redaktion@, werbung@, support@) und Kontaktformular. +_IST 21.05.2026_: Layout vorhanden (`web/kontakt.blade.php`). + +#### Rechtliches + +**14. Impressum** – `/impressum` **15. Datenschutz** – `/datenschutz` **16. AGB** – `/agb` **17. Cookie-Einstellungen** – `/cookies` _(oder Modal)_ + +Diese vier sind Pflicht und nicht zusammenfassbar. +_IST 21.05.2026_: alle vier als Layout vorhanden (`web/impressum.blade.php`, `web/datenschutz.blade.php`, `web/agb.blade.php`, `web/cookies.blade.php`). Inhalte sind teilweise Platzhalter — vor Go-Live durch Anwalt zu pruefen. + +#### Technik / Distribution + +**18. RSS-Feeds-Übersicht** – `/feeds` Liste aller verfügbaren RSS-Feeds (alle, pro Branche, pro Region). Eine Seite, listet alle Feed-URLs auf. +_IST 21.05.2026_: nicht umgesetzt. + +**19. API-Dokumentation** – `/api` Für Distribution-Partner und Pro-/Agency-Kunden mit API-Zugang. +_IST 21.05.2026_: Seite vorhanden (`web/api.blade.php`). Pre-existing `ApiDocumentationTest` ist rot, weil `docs/api/v1.yml` noch fehlt — eigener Track. + +#### DSA-/Rechts-Pflichten + +**20. PM melden** – `/melden/[id]` Öffentlicher Notice-and-Action-Endpoint, eigener Pfad pro PM (kann auch als Modal von der PM-Detailseite kommen, aber direkter Link für rechtssichere Beschwerden besser). +_IST 21.05.2026_: nicht umgesetzt (Phase 2/3, DSA-Pflicht). + +**21. Pressemitteilung verwalten (Magic-Link)** – `/verwalten` Einstiegspunkt für den Pressekontakt-Flow (E-Mail eingeben → Magic Link). Dahinter dann der eingeloggte Verwaltungs-Bereich. +_IST 21.05.2026_: nicht umgesetzt (Phase 2, siehe `Presseportal – Konzept für Relaunch.md` Abschnitt 6). + +--- + +### Was als Modal/Overlay läuft (keine eigene Seite) + +Das sind die Sachen, die man oft in einer separaten Seite versteckt sieht, aber besser inline gelöst werden – kein Kontext-Verlust für den User. + +- **Erweiterte Suche** → Modal mit Filtern (URL-Parameter werden trotzdem gesetzt für Teilbarkeit) +- **Tarife-Übersicht aus CTAs** → Modal (neben der eigenen `/preise`-Seite) +- **Whitepaper-Download mit Lead-Capture** → Modal mit Name/E-Mail-Feldern +- **Newsletter-Anmeldung aus Sektion** → inline ohne Seitenwechsel +- **PM melden aus Detailseite** → Modal (mit Fallback auf eigene URL) +- **Cookie-Einstellungen** → Modal (mit Fallback auf eigene URL für Rechtssicherheit) +- **Login** → Modal (Anmelden-Button öffnet Modal, kein Seitenwechsel; eigene Seite nur als Fallback `/login`) +- **Bild-Lightbox** auf PM-Detailseite → Overlay +- **Teilen-Funktionen** auf PM-Detailseite → Modal mit Plattform-Auswahl und vorgenerierten Texten +- **Tarif-Wechsel im User-Bereich** → Modal +- **Credit-Aufladung** → Mini-Checkout-Modal (war im Konzept schon so geplant) + +--- + +### Was im eingeloggten User-Bereich liegt + +Hier ist wichtig: **alles unter einer einzigen Dashboard-URL**, nicht 15 Untermenüs. Ein Bereich, mehrere Tabs/Sektionen. + +**Publisher-Dashboard** – `/dashboard` + +> **IST-Stand 21.05.2026**: Im Code heisst der Customer-Bereich `/admin/me` +> (Routen-Namen `me.*`); das Admin-Backend liegt unter `/dashboard` und ist +> Editoren/Admins vorbehalten. Die Bereiche im User-Backend sind als +> eigene Pages mit `wire:navigate` (kein vollst. Seitenwechsel) +> umgesetzt und ueber die Sidebar navigierbar. Eine echte +> Tab-Komponente innerhalb einer einzigen URL gibt es nicht — der +> Mehrwert ist gleich. + +Mit folgenden Bereichen als Tabs oder Sidebar-Navigation (kein Seitenwechsel zwischen den Tabs, oder URL-Tabs wie `/dashboard/meldungen`): + +- **Übersicht** – Stats, Credit-Stand, letzte Aktivitäten _(umgesetzt als `customer/dashboard`)_ +- **Meine Pressemitteilungen** – Liste mit Status, Bearbeiten, Korrektur, Update _(umgesetzt als `customer/press-releases/{index,show,create,edit}`)_ +- **Editor** – Neue PM erstellen / bestehende bearbeiten (eigene Unter-URL `/editor` oder `/editor/[id]`) _(umgesetzt als Teil von `press-releases.{create,edit}`)_ +- **Newsroom** – Markenseite konfigurieren (für Pro/Agency) _(nicht umgesetzt — Phase 2)_ +- **Statistiken** – Detail-Auswertungen pro PM _(nicht umgesetzt — Phase 2)_ +- **Credits & Rechnungen** – Stand, Verlauf, Pakete kaufen, Rechnungen herunterladen _(nur Rechnungen umgesetzt; Credits sind Phase 2)_ +- **Tarif & Account** – Tarif-Verwaltung, Rechnungsdaten, Team-Mitglieder (für Agency) _(Profil + Rechnungsadresse umgesetzt; Tarif/Team Phase 2)_ +- **Boost & Platzierungen** – Slot-Buchungen, Verlauf, neue buchen _(als Stub vorhanden `customer/bookings`)_ + +**Pressekontakt-Bereich** (Magic-Link) – `/verwalten/[token]` + +Vereinfachte Version des Dashboards für nicht-registrierte Pressekontakte: + +- Liste der PMs mit dieser E-Mail +- Änderungs-Wizard (Pfade A–G) +- Optional: Account-Anlage für späteren direkten Zugriff + +**Admin-Bereich** – `/admin` _(intern, nicht öffentlich)_ + +Eigene Anwendung im Grunde, aber URL-mäßig unter Hauptdomain: + +- Review-Queue (Gelb-PMs, Beschwerden, Persönlichkeitsrecht-Pfad F) +- User-Verwaltung +- Inventar-Management (welche Slots sind gebucht) +- Editorial-Picks setzen +- Reports / Statistiken + +--- + +### Strukturelle Faustregeln, die ich anwenden würde + +**1. Maximal zwei Klicks ab Startseite zu jeder Funktion.** Aus Startseite → Branchenseite → PM-Detail. Aus Startseite → Veröffentlichen → Tarif-Auswahl. Wenn etwas drei Klicks braucht, ist es falsch verortet. + +**2. Footer ist die Sitemap.** Alle Service- und Rechts-Seiten leben _nur_ im Footer. Keine Mega-Menüs im Header. Die Hauptnavigation oben ist ausschließlich Branchen-Navigation plus Veröffentlichen-CTA. + +**3. URL-Schemata konsistent.** Singular für Detailseiten (`/branche/...`, `/newsroom/...`), Verben für Aktionen (`/veroeffentlichen`, `/melden`, `/verwalten`). Keine kryptischen IDs in URLs, wenn vermeidbar – Slugs für SEO. + +**4. Modals statt Seiten, wenn möglich.** Aber: jeder Modal hat einen Fallback-URL-Endpoint, falls jemand direkt verlinkt oder einen Bookmark setzt. Beispiel: Tarife-Modal → `/preise` als eigene Seite existiert weiterhin. + +**5. Dashboard ist EIN Bereich.** Nicht "Meine PMs" als eigene Seite, "Stats" als andere, "Credits" als dritte – alles unter `/dashboard` mit Tabs. Reduziert kognitive Last und Navigation. + +--- + +### Zusammenfassung als Liste zum Abhaken + +**Öffentliche Inhalts-Seiten (6):** Detailseite, Branche, Region, Newsroom, Suche, Thema + +**Service-/Vertriebs-Seiten (4):** Veröffentlichen, Preise, Mediadaten, Newsletter + +**Vertrauen/Editorial (3):** Über uns, Redaktion, Kontakt + +**Rechtliches (4):** Impressum, Datenschutz, AGB, Cookies + +**Technik/Distribution (2):** Feeds, API-Doku + +**DSA-Pflicht (2):** Melden, Verwalten (Magic-Link-Einstieg) + +**Eingeloggte Bereiche (3):** Dashboard, Pressekontakt-Bereich, Admin + +**Macht insgesamt 24 echte Seiten/Bereiche** – das ist für eine Plattform dieser Tiefe sehr schlank. Vergleichswert: presseportal.de hat über 80 Seiten in der Sitemap. \ No newline at end of file diff --git a/docs/KI-UND-ENTWICKLER-WORKFLOW.md b/docs/KI-UND-ENTWICKLER-WORKFLOW.md new file mode 100644 index 0000000..ada921c --- /dev/null +++ b/docs/KI-UND-ENTWICKLER-WORKFLOW.md @@ -0,0 +1,51 @@ +# KI- und Entwickler-Workflow (Pressekonto) + +Kurzanleitung für Arbeit im Dev-Container: Dokumentation (Obsidian), Issues (Forgejo/`tea`), Git. + +## 1. Dokumentation und Kontext (Obsidian) + +- **Speicherort:** Markdown-Dateien liegen im Projekt unter **`docs/`** (im Container: `/var/www/html/docs/`). Dieser Ordner ist per Dev-Container mit dem Obsidian-Vault verbunden; Änderungen synchronisieren mit dem lokalen Vault. +- **Bei Fragen nach „Kontext“, „Briefing“ oder „Plan“:** Zuerst **`docs/`** nach passenden `.md`-Dateien durchsuchen (Dateinamen und Überschriften). +- **Neue Inhalte:** Konzepte, Pläne, Briefings und Spezifikationen **immer als `.md` in `docs/`** ablegen. Sinnvolle, stabile Dateinamen wählen (z. B. `feature-stripe-briefing.md`). + +## 2. Issue-Management (Forgejo mit `tea`) + +**Nur das `tea`-CLI im Terminal verwenden** — keine direkten HTTP/cURL-Aufrufe auf die Forgejo-API für Issue-Operationen. + +| Aktion | Befehl | +|--------|--------| +| Issues auflisten | `tea issue list` | +| Issue anzeigen | `tea issue view ` | +| Neues Issue (Beschreibung aus Datei) | `tea issue create --title "Kurztitel" --description "$(cat docs/beispiel.md)"` | +| Issue aktualisieren | `tea issue edit --description "$(cat docs/update.md)"` | + +**Hinweise:** + +- Nach `postCreateCommand` liegt `tea` typischerweise unter `~/.local/bin`; Shell neu starten oder `export PATH="$HOME/.local/bin:$PATH"` setzen, falls `tea` nicht gefunden wird. +- Remote/Ziel prüfen bei Bedarf mit `tea repos` bzw. der in `postCreateCommand` konfigurierten Login-Instanz **`gitmedia`** (`https://git.adametz.media`). +- **`tea` ist im Setup bereits authentifiziert** — keine Zugangsdaten oder Tokens im Chat abfragen oder in Repos committen. Token-Datei nur read-only gemountet (`/tmp/.forgejo_token`). + +**Weitere Unterbefehle:** `tea issue --help`, `tea issue create --help`. + +## 3. Git und Commits (Issue-Schließen) + +1. **Conventional Commits:** Präfixe wie `feat:`, `fix:`, `refactor:`, `docs:`, `chore:` verwenden. +2. **Issue-Verknüpfung zum automatischen Schließen:** Im Commit-Text die Issue-Nummer mit **`closes`** (alternativ in vielen Gitea/Forgejo-Setups auch `fix(es)`, `resolve(s)`) angeben. + + **Beispiel:** + + ```text + feat: Stripe-Zahlung implementiert, closes #12 + ``` + +3. **Branching:** Projekt-Branches und MR/PR-Regeln wie im Team vereinbart beibehalten. + +## 4. Arbeitsweise (KI / Agent) + +- Kontext zuerst aus **`docs/`**, dann Code und bestehende Projektregeln (z. B. `AGENTS.md`, `.cursorrules`). +- Terminal-Befehle direkt ausführen, wo sinnvoll; Antworten **prägnant**, Entscheidungen und Änderungen **nachvollziehbar** dokumentieren (bei Bedarf kurz in `docs/` oder im Issue). +- Keine manuelle Nachfrage nach Forgejo-Zugang **sofern `tea login` für `gitmedia` gesetzt ist**; bei `tea`-Fehlern Umgebung/Remote prüfen, nicht nach Passwörtern fragen. + +--- + +*Letzte inhaltliche Ausrichtung: Dev-Container `workspaceFolder` `/var/www/html`, Vault-Bind nach `docs/`.* diff --git a/docs/PHASE-8-USER-PANEL-PLAN.md b/docs/PHASE-8-USER-PANEL-PLAN.md new file mode 100644 index 0000000..2839012 --- /dev/null +++ b/docs/PHASE-8-USER-PANEL-PLAN.md @@ -0,0 +1,612 @@ +# Phase 8 · User-Panel-Konsolidierung & Pressemitteilungs-Lifecycle + +Stand: 2026-05-29 — **abgeschlossen** (alle Päckchen 8A–8K umgesetzt). +Vorgänger: Phase 7 (Press-Release-Form-Refactor — abgeschlossen) +Abgleich-Doku: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md) +Roadmap-Abschluss: [`dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md`](../dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md) + +> **Status 29.05.2026**: Alle Sub-Päckchen abgeschlossen. Bewusste +> Abweichung in 8H: Upload-Control bleibt `flux:input type=file` statt +> `flux:file-upload` (Stabilität); die Lizenz-Pflichtfelder sind vollständig +> umgesetzt. Der Rechtstext im Veröffentlichungs-Modal (8I) ist ein +> Platzhalter und vor Go-Live anwaltlich zu prüfen. + +--- + +## 0. Worum es geht + +Phase 8 bündelt drei thematisch zusammenhängende Bündel: + +1. **User-Panel-Konsolidierung** — Lücken aus Phase 7 schließen, Firmen-Liste + auf das Mockup-Niveau heben (`dev/frontend/tailwind_v3/User Firmen presseportale.html`). +2. **Pressemitteilungs-Titelbild & SVG-Platzhalter** — jede PM bekommt ein + sichtbares Hero-Bild, entweder eigener Upload oder ein farbiger + SVG-Platzhalter aus einem definierten Set. +3. **Veröffentlichungs-Modal mit rechtlichem Hinweis + Quota-Vorbereitung** + — Pressemitteilungen werden nur über ein bewusstes Modal eingereicht; + in dem Modal steht ein rechtlicher Hinweis und die Information, dass + ein PM-Kontingent verbraucht wird. + +Alle Änderungen, die das User-Panel betreffen, werden **konsistent ins +Admin-Panel übertragen**, sobald sie inhaltlich passen (z. B. +Show-Pages, Listen-Indikatoren, Bild-Manager-Wrapper). + +--- + +## 1. Sub-Päckchen-Übersicht + +| ID | Thema | Größe | Risiko | +|---|---|---|---| +| **8A** | Show-Page-Lücken schließen (subtitle, scheduling, embargo, boilerplate_override) — Customer + Admin | S | gering | +| **8B** | Listen-Indikatoren für Scheduling/Embargo — Customer + Admin | S | gering | +| **8C** | Pressekontakt-Warnung in Sidebar-Card (Customer + Admin) + Tests | XS | gering | +| **8D** | Doku-Pflege: Phase-7-Schlussfeinheiten in `19-PHASE-7-…md` + Konzept-Anpassungen aus Abgleich | S | keine | +| **8E** | Firmen-Liste auf Mockup-Niveau (Counter-Strip, Saved-Views, Filter-Chips, Card/List-Toggle, Rollen-Legende) | L | mittel | +| **8F** | SVG-Platzhalter-Set extrahieren + auswählbar machen (Customer-Modal) | M | mittel | +| **8G** | PressRelease-Titelbild — Schema, Default-Platzhalter, Vorschau im Form | M | mittel | +| **8H** | FluxUI `flux:file-upload` im Image-Manager + Pflichtfelder Urheber/Lizenz/Rechte | M | mittel | +| **8I** | Veröffentlichungs-Modal mit Rechts-Hinweisen + Quota-Anzeige (Customer) + Hook | M | mittel | +| **8J** | Quota-Stub: Demo-Counter im Datenmodell + Decrement-Hook im PressReleaseService | M | hoch (Datenmodell) | +| **8K** | Tests + Pint + Build + Roadmap-Update + Abschluss-Eintrag in `PROGRESS.md` | S | gering | + +**Abkürzungen**: XS = < 1 h, S = 1–3 h, M = 3–8 h, L = 8–16 h. + +Reihenfolge entspricht dem geplanten Ablauf. Nach jedem Päckchen ist ein +Review-Stopp mit dem User vorgesehen, bevor das nächste startet. + +--- + +## 2. Päckchen im Detail + +### 8A · Show-Page-Lücken schließen + +**Ziel**: Customer-Show und Admin-Show zeigen die Phase-7-Felder. + +**Anpassungen**: + +- `resources/views/livewire/customer/press-releases/show.blade.php` + - Untertitel direkt unter H1 als kleinere Headline anzeigen + - „Geplante Veröffentlichung"-Card mit `scheduled_at`, falls gesetzt + - „Embargo bis"-Card mit `embargo_at`, falls gesetzt + - „Boilerplate (Override)"-Card, falls `boilerplate_override` befüllt +- `resources/views/livewire/admin/press-releases/show.blade.php` + - Untertitel + - Boilerplate-Override (Scheduling/Embargo sind bereits da) + +**Tests**: bestehende Show-Tests erweitern um Assertions für die neuen +Sichtbarkeiten. + +**Akzeptanz**: Customer- und Admin-Show stellen exakt dieselben PM-Felder +dar, die in den Forms gepflegt werden können (außer Anhänge — deaktiviert). + +--- + +### 8B · Listen-Indikatoren für Scheduling/Embargo + +**Ziel**: In beiden PM-Listen sieht man auf einen Blick, welche PMs +geplant sind oder unter Embargo stehen. + +**Anpassungen**: + +- `customer/press-releases/index.blade.php`: + - In der Datums-Spalte: zusätzliches Sub-Label „geplant · 21.05. 14:00" + bzw. „Embargo bis 21.05." +- `admin/press-releases/index.blade.php`: + - Analog + +**UI-Pattern**: Mono-Sub-Zeile unter dem Hauptdatum, wenn `status = +review` UND `scheduled_at`/`embargo_at` gesetzt. + +**Tests**: ein neuer Pest-Test pro Liste, der ein PM mit +`scheduled_at`/`embargo_at` erstellt und die Sub-Zeile assertet. + +--- + +### 8C · Pressekontakt-Warnung im Form-Sidebar + +**Ziel**: Wenn keine Pressekontakt in einer PM gewählt ist, zeigt die +Sidebar-Card eine dezente Warn-Box (analog zur „Telefonnummer fehlt"- +Warnung), damit klar ist, dass die PM zwar speicherbar ist, aber +einen Kontakt empfehlen sollte. + +**Anpassungen**: + +- `customer/press-releases/create.blade.php` + `edit.blade.php`: + - Im Pressekontakt-Sidebar-Card: + ```blade + @if (! $contactId) +
+ + Es wurde noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend. +
+ @endif + ``` +- `admin/press-releases/create.blade.php` + `edit.blade.php`: identisch + +**Tests**: Form-Render-Test mit ohne-Kontakt-Setup, das den Warn-String +assertet. + +--- + +### 8D · Doku-Pflege + +**Ziel**: `19-PHASE-7-PRESS-RELEASE-FORM.md` und Konzept-Dokumente an +den IST-Stand anpassen, damit zukünftige Phasen auf einer sauberen +Basis aufsetzen. + +**Konkret**: + +- `dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md`: + - Block ergänzen: „Anhänge deaktiviert; Tests skipped; siehe Security-Review" + - Block ergänzen: „Pressekontakt nullable; Warnung im Sidebar" + - Block ergänzen: „Sidebar-Reihenfolge: Status → Kategorie → Portal + (Pill) → Pressekontakt → Themen-Tags → Veröffentlichung → Weitere + Felder → Phase-2-Footer" +- `docs/user-admin/Admin-User.md`: Aktualisierungen aus + [`STATUS-ABGLEICH-USER-PANEL.md` Abschnitt 6.1](./STATUS-ABGLEICH-USER-PANEL.md#61-sofort-ohne-risiko-machbar). +- `docs/user-admin/checkliste-user-backend.md`: neuen Phase-7-Block + hinzufügen. + +**Akzeptanz**: Wer die `docs/`-Hauptdokumente liest, bekommt einen +zutreffenden Eindruck vom IST-Stand. + +--- + +### 8E · Firmen-Liste auf Mockup-Niveau + +**Status**: ✅ abgeschlossen (21.05.2026) + +**Was umgesetzt wurde** + +- `resources/views/livewire/customer/press-kits/index.blade.php` komplett + überarbeitet (Volt + Hub-Tokens). +- Neue Hub-Tokens in `resources/css/shared/hub-components.css`: `.firm-card`, + `.add-tile`, `.seg-toggle`, `.role-pill`, `.mini-logo`, `.lg-*`-Logo-Varianten, + `.menu-trigger`, `.card-action`, `.page-btn`. +- Counter-Strip mit `Firmen · aktiv · PMs gesamt · Pressekontakte hinterlegt`. +- Saved-View-Tabs `Alle/Aktiv/In Anlage/Inaktiv/Mit mir geteilt`, mit + Live-Counts. „In Anlage" ist bewusst noch leer (Phase-2-Heuristik). +- Filter-Chips (Portal, Rolle) via FluxUI-Dropdown + URL-Sync (`?view=…&portal=…&role=…&mode=…`). +- View-Toggle Karten / Liste, persistiert in der URL als `?mode=list`. +- Karten- und Listen-Ansicht mit Hub-Look, deterministische Logo-Varianten, + Status-Badge, Portal-Pills, Rolle-Pill, KPIs (PMs / Kontakte / letzte PM), + Aktionen „Firma öffnen" und „Neue PM". +- Add-Tile auf der letzten Seite (CTA: `Firma anlegen anfragen` → Profil). +- Empty-States: 3-Schritt-Onboarding (keine Firmen) und Reset-CTA (Filter + ohne Treffer). +- Rollen-Legende als `panel-warm` mit Owner / Verantwortlich / Mitglied. +- `tests/Feature/CustomerPressKitIndexPhase8eTest.php` mit 14 Tests + (Counter, Saved-Views, Filter, View-Mode, Empty-States, Add-Tile, + Rollen-Legende). + +**Ziel**: `customer/press-kits/index.blade.php` entspricht dem Mockup +`dev/frontend/tailwind_v3/User Firmen presseportale.html`. + +**Mockup-Komponenten** (alle CSS-Klassen aus +`resources/css/shared/hub-components.css` bereits vorhanden oder leicht +ergänzbar): + +- **Counter-Strip** (`.counter-strip`): `X Firmen · X aktiv · X PMs gesamt · X Pressekontakte` +- **Saved-View-Tabs** (`.view-tabs`): `Alle / Aktiv / In Anlage / Inaktiv / Mit mir geteilt` + - „In Anlage" = neue Firma, die noch keine Stammdaten hat (heuristisch: kein Logo + keine PMs) + - „Mit mir geteilt" = `company_user.role IN (member, responsible)` ohne `owner_user_id = me` +- **Filter-Chips** (`.filter-chip`): + - Status (Aktiv/Inaktiv/Alle) + - Portal (presseecho/businessportal24/Alle) + - Rolle (Admin/Redakteur/Beobachter/Alle) — „Admin"-Begriff für Owner, + „Redakteur" für `responsible`, „Beobachter" für `member` + - Branche (Auswahl aus vorhandenen `Industry`-Werten) +- **Seg-Toggle** (`.seg-toggle`): Karten- vs. Listen-Ansicht (Default Karten) +- **Karten** (`.firm-card`): + - Logo (Hub-Token-Box oder echtes Logo) + - Status-Badge + - Portal-Pills (eine oder zwei, je nach Firma-Portal) + - Rolle-Pill (`.role-pill.admin` für Owner) + - KPIs: PMs, Pressekontakte, Datum letzte PM + - Aktionen: „Bearbeiten" (zur Firma) + „Neue PM" (Editor mit Firma vorgewählt) + - `is-self`-Highlight für die aktive Firma aus dem Context-Switcher +- **Add-Tile**: „Neue Firma anlegen" — derzeit nur Anfrage-Link auf Profil + (Self-Service-Anlage ist Phase-2-Thema) +- **Empty-States**: + - Keine Firma: 3-Schritt-Onboarding (Stammdaten → Boilerplate → Pressekontakte) — derzeit nur Anfrage-CTA + - Filter ohne Treffer: Reset-CTA +- **Rollen-Legende** am Ende als `panel-warm` + +**Volt-Anpassungen**: + +- Computed Properties: `statusCounts`, `portalOptions`, `roleOptions`, + `industryOptions` +- Neue Properties: `$statusFilter`, `$portalFilter`, `$roleFilter`, + `$industryFilter`, `$viewMode` (cards|list) +- Methoden: `setView($status)`, `resetFilters()`, `toggleViewMode()` + +**Was bewusst NICHT in 8E kommt**: + +- Echte Self-Service-Firma-Anlage (Phase 2) +- Statistik-Tab in Firmen-Detail (Phase 2) +- Abrechnung pro Firma (Phase 2) + +**Tests**: + +- Komponente rendert mit 0/1/N Firmen +- Filter-Kombinationen +- View-Mode-Toggle +- Counter-Strip-Zahlen stimmen mit den Filtern + +**Admin-Spillover**: Die `admin/companies/index.blade.php` hat einen +ähnlichen, älteren Mockup-Stand. Wenn Zeit übrig: Counter-Strip und +Saved-Views auch dort einbauen (separater Patch im selben Päckchen, +nur wenn ohne Mehraufwand machbar — sonst Phase 9). + +--- + +### 8F · SVG-Platzhalter-Set + +**Ziel**: Ein wiederverwendbares Set von Hero-Platzhaltern für PMs ohne +echtes Titelbild. + +**Quelle**: Bestehende inline SVGs in den Landing-Page-Komponenten: + +- `resources/views/components/web/focus-hero.blade.php` (760×500, Punkt-Pattern + Kreise) +- `resources/views/components/web/feed-top-item.blade.php` (240×160, Linien + Punkte) +- ggf. weitere aus `industry-spotlight`, `quality-summary`, `live-ticker` + +**Neue Struktur**: + +``` +resources/views/components/portal/press-release-placeholder.blade.php +public/images/press-release-placeholders/ + 01-grid-blue.svg + 02-grid-green.svg + 03-grid-amber.svg + 04-lines-blue.svg + 05-lines-green.svg + 06-lines-amber.svg + 07-dots-blue.svg + 08-dots-green.svg + 09-dots-amber.svg +``` + +Jede SVG ist 1600×900 (Hero-Aspect-Ratio 16:9), entspricht dem Bildformat +des `large`-Variant aus `ImageService`. + +**Komponente**: + +```blade + +``` + +**Modal-Auswahl** (Volt-Sub-Komponente): + +``` +resources/views/livewire/components/press-release-placeholder-picker.blade.php +``` + +- Grid 3×3 mit Vorschau aller Varianten +- Wire-Event `placeholderSelected($variant)` → Parent setzt + `placeholder_variant` und schließt das Modal + +**Tests**: Komponente rendert mit jeder Variante; Picker emittiert +korrektes Event. + +--- + +### 8G · Titelbild-Schema & Default-Logik + +**Ziel**: Jede PM hat **immer** ein Hero-Bild — entweder echtes Bild +oder Platzhalter. + +**Schema-Änderung**: + +Migration: `add_placeholder_variant_to_press_releases.php` + +```php +$table->string('placeholder_variant', 32)->nullable()->after('boilerplate_override'); +``` + +**Default-Logik**: + +- `PressRelease::booted` → bei `creating`: wenn `placeholder_variant` + leer, würfle eine Variante aus dem Set +- alternativ in `customer/press-releases/create.blade.php`: nach + `mount()` Default setzen + +**Cover-Image-Resolver** (Service): + +``` +app/Services/PressRelease/PressReleaseCoverImage.php +``` + +Public-Methoden: + +- `coverUrl(PressRelease $pr, string $variant = 'large'): string` + - wenn echtes Preview-Bild da → `variantUrl($variant)` + - sonst → `asset('images/press-release-placeholders/'.$pr->placeholder_variant.'.svg')` +- `coverIsPlaceholder(PressRelease $pr): bool` + +**Verwendung**: + +- Hero in Customer-Show, Admin-Show, Public-Detail-Page +- Thumb in beiden Listen +- Vorschau im Form + +**Tests**: Resolver-Unit-Test für beide Fälle. + +--- + +### 8H · FluxUI File-Upload + Lizenzfelder + +**Ziel**: Image-Manager nutzt `flux:file-upload`, erfasst die rechtlich +nötigen Felder, eckende UI passt zum Mockup. + +**Anpassungen** in `resources/views/livewire/components/press-release-images-manager.blade.php`: + +- `` + (Dropzone-Style aus FluxUI) +- Zusätzliche Felder als FluxUI-Inputs: + - **Urheber/Fotograf** (`flux:input` required) + - **Lizenztyp** (`flux:select` mit Enum-Werten): + - Eigene Aufnahme + - CC-Lizenz (Lizenz-URL Pflicht) + - Kommerzielle Lizenz erworben (Lizenz-URL Pflicht) + - Einwilligung des Urhebers + - Sonstiges + - **Lizenz-URL** (`flux:input` conditional required) + - **Personen-Einwilligung** (`flux:checkbox` optional) + - **Rechte-Bestätigung** (`flux:checkbox` required, mit AGB-Text aus + `Presseportal – Konzept für Relaunch.md` Abschnitt 2) + +**Schema**: + +Migration: `add_license_fields_to_press_release_images.php` + +```php +$table->string('author')->nullable(); +$table->string('license_type', 32)->nullable(); +$table->string('license_url')->nullable(); +$table->boolean('persons_consent')->default(false); +$table->timestamp('rights_confirmed_at')->nullable(); +``` + +Enum: `App\Enums\ImageLicenseType` (PHP-Enum mit Labels). + +**Tests**: + +- Upload ohne Urheber → Validierungs-Fehler +- Upload mit `license_type = cc` ohne `license_url` → Fehler +- Upload mit allen Pflichtfeldern → erfolgreich, `rights_confirmed_at` gesetzt + +**UI-Skizze** (Form-Reihenfolge): + +``` +┌─────────────────────────────────────┐ +│ [flux:file-upload Dropzone] │ +├─────────────────────────────────────┤ +│ Urheber: [input] * │ +│ Lizenztyp: [select] * │ +│ Lizenz-URL: [input] (*) │ +│ [ ] Personen-Einwilligung │ +│ [ ] Ich bestätige die Rechte * │ +│ │ +│ [Hochladen] │ +└─────────────────────────────────────┘ +``` + +--- + +### 8I · Veröffentlichungs-Modal + +**Ziel**: Customer kann eine PM nur über ein explizites Modal mit +rechtlichem Hinweis und Quota-Information einreichen. + +**Anpassungen** in `customer/press-releases/edit.blade.php`: + +- „Zur Prüfung einreichen"-Button löst Modal-Open aus statt direkt + `submitForReview` zu rufen +- Modal-Inhalt: + - Eyebrow „Veröffentlichung" + - H3 „Pressemitteilung zur Prüfung einreichen" + - Block „Rechtliche Hinweise" (aus Konzept-Abschnitt 5 zur + DSGVO-Position + Abschnitt 2 zu Bildrechten, plus AGB-Verweis) + - Block „Kontingent" — Anzeige `Ihre verbrauchten PMs in diesem Monat: X / Y` + - Block „Bestätigungen": + - [ ] Inhalt entspricht den AGB + - [ ] Bildrechte sind geklärt + - [ ] Pressekontakt-Daten korrekt + - Footer: „Abbrechen" (sekundär) + „Veröffentlichung anfordern" (primär, + disabled bis alle 3 Checkboxen gesetzt) +- Confirm-Button ruft `submitForReview()` (existiert bereits) + +**Schreibweise** (juristisch sicherer Ankertext): + +``` +Mit dem Einreichen dieser Pressemitteilung versichern Sie: +- Sie sind befugt, den Inhalt zu veröffentlichen. +- Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis. +- Personenbezogene Daten sind nur in dem für die Berichterstattung + zwingend erforderlichen Umfang enthalten. +- Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig. + +Sie stellen [Plattform] von Ansprüchen Dritter frei, die aus einer +unberechtigten Nutzung von Inhalten resultieren. Die endgültige +Veröffentlichung erfolgt nach redaktioneller Prüfung. +``` + +(Exakte Formulierung muss vor Go-Live durch einen Anwalt geprüft werden — +für die Bauphase reicht der Platzhalter.) + +**Quota-Anzeige**: + +In Phase 8I noch ohne echte Tarif-Logik: Anzeige eines **Demo-Counters**, +der aus einer einfachen Aggregation (`User::pressReleasesPublishedThisMonth()`) +kommt — und einer hartcodierten Obergrenze (z. B. 3 als Starter-Default). +Das echte Tarif-System kommt in einer späteren Phase. + +**Tests**: + +- Modal öffnet sich bei Klick +- Submit-Button bleibt disabled bis alle 3 Checkboxen gesetzt +- Nach Bestätigung: PM-Status ist `review` +- Toast-Bestätigung wird angezeigt + +**Admin-Spillover**: Im Admin-Editor reicht der Admin direkt ein +(`publish`), kein Modal nötig — der Hinweis ist auf den Customer-Flow +zugeschnitten. + +--- + +### 8J · Quota-Stub + +**Ziel**: Die UI-Anzeige in 8I hängt am echten Datenmodell, auch wenn +das vollständige Tarif-/Credit-System erst später kommt. + +**Datenmodell** (minimal): + +Migration: `create_user_quota_table.php` oder als JSON in `profiles`? + +> **Entscheidung**: Wir machen es als Migration-light auf `users`: +> +> ```php +> $table->unsignedInteger('press_release_quota')->default(3)->after('settings'); +> $table->unsignedInteger('press_release_quota_used_this_month')->default(0)->after('press_release_quota'); +> ``` +> +> Diese Spalten sind temporär, das echte Tarif-Modell überschreibt sie +> oder ersetzt sie durch eigene Tabellen. + +**Service** in `PressReleaseService::submitForReview`: + +```php +$user->increment('press_release_quota_used_this_month'); +``` + +(Reset des Counters per Scheduled-Command monatlich → eigener kleiner +Befehl `ResetMonthlyPressReleaseQuota`.) + +**API für die View** (z. B. via `User`-Method): + +```php +public function pressReleaseQuotaRemaining(): int +{ + return max(0, $this->press_release_quota - $this->press_release_quota_used_this_month); +} +``` + +**Akzeptanz**: Veröffentlichungs-Modal zeigt sinnvolle Zahlen, der +Counter erhöht sich nach Submit, der Scheduler-Command resettet ihn +zum 1. des Monats. Tarif-Anbindung folgt später. + +**Tests**: + +- Counter inkrementiert bei `submitForReview` +- Counter resettet via Scheduled-Command (Unit-Test) + +--- + +### 8K · Tests, Pint, Build, Roadmap + +**Ziel**: Saubere Übergabe. + +- `vendor/bin/sail artisan test --compact` muss durchlaufen (außer + pre-existing `ApiDocumentationTest`). +- `vendor/bin/sail bin pint --dirty --format agent` clean. +- `vendor/bin/sail npm run build:portal` clean. +- `dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md` als neue + Roadmap-Doku (analog zu `19-…`). +- Eintrag in `dev/frontend/hub-flux/PROGRESS.md` mit allen Sub-Päckchen. +- `docs/user-admin/checkliste-user-backend.md` um Phase-8-Block ergänzen. + +--- + +## 3. Was außerhalb von Phase 8 bleibt + +Bewusst nicht in Phase 8: + +- **Magic-Link-Flow für Pressekontakte** → Phase 9 oder Phase 2 lt. Konzept +- **Statistik-Tab in Firmen-Detail** → Phase 9 +- **Self-Service-Firmen-Anlage** → Phase 9 +- **Notice-and-Action für externe Meldungen** → Phase 2/3 +- **KI-Vorprüfung** → Phase 2/3 +- **Korrektur-/Update-Hinweis-System** → Phase 2/3 +- **Echtes Tarif-/Credit-System mit Stripe** → eigene Phase +- **Trust-Score / Score-System** → Phase 3 +- **Anhänge-Reaktivierung** → eigener Sicherheits-Audit-Track + +--- + +## 4. Reihenfolge & Review-Punkte + +Vorgeschlagene Reihenfolge der Päckchen: + +``` +8D (Doku zuerst — bewegt sich nichts am Code) +→ 8A (Show-Page-Lücken) +→ 8B (Listen-Indikatoren) +→ 8C (Pressekontakt-Warnung) +→ Review-Stopp mit User +→ 8E (Firmen-Liste auf Mockup) — größtes Päckchen, Review davor +→ Review-Stopp +→ 8F (SVG-Platzhalter extrahieren) +→ 8G (Titelbild-Schema + Default) +→ 8H (FluxUI File-Upload + Lizenzfelder) +→ Review-Stopp +→ 8J (Quota-Stub im Datenmodell zuerst, damit 8I darauf aufsetzen kann) +→ 8I (Veröffentlichungs-Modal) +→ 8K (Abschluss) +``` + +**Begründung**: Doku zuerst, weil der Abgleich sonst veraltet. Dann +kleine UX-Lücken (8A–8C), die schnelle Wins sind. Anschließend die +größere Firmen-Liste (8E), die ein eigenes Päckchen ist. Bild- und +Veröffentlichungs-Block am Ende, weil sie thematisch zusammengehören +und Schema-Änderungen mitbringen. + +--- + +## 5. Risiken & Annahmen + +- **Annahme**: FluxUI `flux:file-upload` ist in der aktuellen Version + voll funktional und kompatibel mit `WithFileUploads` von Livewire. + Fallback: bestehender Standard-Upload bleibt erhalten. +- **Annahme**: SVG-Platzhalter (1600×900) sind klein genug, dass wir + sie direkt aus `public/images/...` ausliefern — kein CDN-Setup nötig. +- **Risiko**: Schema-Änderungen in 8G + 8H + 8J berühren produktive + Tabellen (`press_releases`, `press_release_images`, `users`). Alle + Migrations sind additive (nullable + default), Rollback-fähig. +- **Risiko**: Quota-Stub in 8J wird vom echten Tarif-System abgelöst + — Code-Schnittstelle (`pressReleaseQuotaRemaining()`) muss stabil bleiben, + damit das Veröffentlichungs-Modal nicht neu gebaut werden muss. +- **Risiko**: Rechtstext im Veröffentlichungs-Modal ist Platzhalter. + Vor Go-Live durch Anwalt zu prüfen. + +--- + +## 6. Akzeptanzkriterien Phase 8 gesamt + +- [x] Customer-Show + Admin-Show stellen alle Phase-7-Felder dar +- [x] PM-Listen markieren Scheduling und Embargo +- [x] Pressekontakt-Sidebar warnt bei leerer Auswahl +- [x] `docs/user-admin/*` ist mit dem Code synchron +- [x] Firmen-Liste entspricht dem Mockup zu ≥ 90 % +- [x] Jede PM hat ein sichtbares Hero-Bild (echtes oder Platzhalter) +- [x] Image-Upload erfasst Urheber + Lizenz-Typ + Rechte-Bestätigung +- [x] „Zur Prüfung einreichen" erfordert eine bewusste Modal-Bestätigung +- [x] Quota-Counter inkrementiert pro Einreichung, resettet monatlich +- [x] Tests grün (375 passed, 4 skipped — inkl. pre-existing `ApiDocumentationTest`) +- [x] Pint clean, Build clean +- [x] Roadmap-Eintrag und `PROGRESS.md`-Block geschrieben + +--- + +## 7. Nächster Schritt + +Mit **8D (Doku)** starten, weil das ohne Code-Änderungen funktioniert +und den Boden für die folgenden Päckchen ebnet. Direkt im Anschluss +**8A–8C** als Block, weil sie zusammen die Phase-7-Lücken schließen. + +Danach Review-Stopp für Phase 8E (Firmen-Liste) — das ist das +sichtbarste Päckchen für den User und sollte mit klarem Mockup-Vergleich +abgenommen werden. diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md new file mode 100644 index 0000000..17fcfe0 --- /dev/null +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -0,0 +1,289 @@ +# Phase 9 · Veröffentlichungs-Flow (Launch) & Tarif-Modul + +Stand: 2026-06-12 — **Block 1 (9A–9C) abgeschlossen**; Review-Stopp vor +Block 2 (9D–9J, Tarif-Modul). Suite nach Block 1: 451 passed, 4 skipped. +Vorgänger: Phase 8 (User-Panel-Konsolidierung) + KI-Prüf-Pipeline (beide abgeschlossen). +Verbindliche Entscheidungen: [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) +Abgleich-Doku: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md) + +--- + +## 0. Worum es geht + +Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: + +1. **Block 1 — Veröffentlichungs-Flow (9A–9C)**: Die Flow-Regeln, die + unabhängig vom Tarif-Modul gelten und auf denen das Tarif-Modul aufsetzt. + Funktioniert vollständig mit dem vorhandenen Quota-Stub. +2. **Block 2 — Tarif-Modul (9D–9I)**: Zahlung, Tarife, Einzel-PM, Tageslimit + und die drei Launch-Credit-Posten. Löst den Quota-Stub ab. + +**Leitplanken aus dem Decision-Update:** + +- Gelb geht zum Launch **direkt live** wie Grün — keine manuelle Prüf-Queue. + Nur Rot wird abgelehnt (Meldung mit Begründung an den Autor). +- Der PM-Slot zählt **bei Veröffentlichung** runter, nicht bei der Prüfung. + Rot verbraucht keinen Slot. +- „Speichern" bleibt immer frei; „Speichern & zur Prüfung einreichen" ist + hinter eine aktive Buchung gegated (der Button konvertiert, er verschwindet + nicht). +- Kein Re-Check zum Launch: eine Einreichung = eine Prüfung = (bei Gelb/Grün) + eine Veröffentlichung. Vorab-Prüfung/Redigieren sind Phase 2. + +--- + +## 1. Sub-Päckchen-Übersicht + +| ID | Thema | Größe | Risiko | +|---|---|---|---| +| **9A** ✅ | Gelb-Routing auf Direkt-Live umstellen (Routing, Scheduler, Tests) | S | gering | +| **9B** ✅ | Slot-Verbrauch von Einreichung auf Veröffentlichung umstellen (Rot = kein Slot) | M | mittel (Idempotenz) | +| **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering | +| — | **Review-Stopp mit User** | | | +| **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) | +| **9E** ✅ | Stripe-Anbindung: Produkt-Sync (Tarife + Einzel-PM), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, Endpoint registriert), Checkout-Flows (Backend), Slot-Logik auf Plan-Kontingent (Grandfathered = unbegrenzt), Stripe Tax | L | mittel | +| **9F** ✅ | Tarif-Seite + Checkout-UI: Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, „2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige, „Abo verwalten" (Stripe Billing Portal), Enterprise-Hinweis | M | gering | +| **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering | +| **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel | +| **9I** | Launch-Credits: Extra-PM, Boost (nur Grün), Veröffentlichungsnachweis-PDF | L | mittel | +| **9J** | Abschluss: Tests, Pint, Build, Doku-Sync, PROGRESS-Eintrag | S | keine | + +Nach jedem Päckchen Review-Stopp mit dem User; vor 9D ein größerer +(Datenmodell-Entscheidungen + Cashier-Freigabe). + +--- + +## 2. Block 1 — Veröffentlichungs-Flow + +### 9A · Gelb-Routing auf Direkt-Live + +**Entscheidung (12.06.2026)**: Rot = nicht veröffentlichbar (rechtlich/ +inhaltlich) → Ablehnung mit Meldung. Gelb/Grün = veröffentlichbar → geht in +der ersten Phase direkt online. Gelb bleibt als interne Markierung erhalten +(nicht boostbar, Admin-Signal), löst aber keine manuelle Prüfung aus. + +**Anpassungen:** + +- `PressReleaseService::routeByClassification()`: Gelb durchläuft denselben + Auto-Publish-Pfad wie Grün (`autoPublishGreen()` → generalisiert zu + `autoPublishApproved()`); Verzögerungsfenster + (`scoring.classification.green_delay_minutes`) gilt für beide. +- `PublishScheduledPressReleases`: Kandidaten-Query von + `classification = green` auf `classification IN (green, yellow)`. +- Admin-Review-Queue bleibt als **Fallback** bestehen: unklassifizierte PMs + (Job noch nicht gelaufen / KI-Ausfall ohne Fallback-Ergebnis) bleiben in + `review` und sind manuell behandelbar. KI-Badge und Klassifikations-Filter + im Admin bleiben unverändert. + +**Tests:** `PressReleaseClassificationJobTest` (Gelb-sofort → published, +Gelb-geplant → bleibt review bis Termin), `PressReleaseSchedulingTest` +(gelbe fällige PM wird publiziert). + +### 9B · Slot-Verbrauch bei Veröffentlichung + +**Regel:** Der Slot zählt genau einmal pro PM, beim **ersten** Übergang zu +`published`. Rot abgelehnte PMs verbrauchen nichts. + +**Anpassungen:** + +- `submitForReview()`: Increment von `press_release_quota_used_this_month` + **entfernen**. Stattdessen Guard: Einreichen erfordert + `pressReleaseQuotaRemaining() > 0` (sonst würde eine grüne PM ohne + verfügbaren Slot veröffentlicht). +- `publish()`: Increment beim Statuswechsel auf `published`, idempotent — + nur wenn die PM zuvor noch nie veröffentlicht war (Prüfung über + `press_release_status_logs`, kein neues Schema-Feld). Zählt auf den + PM-Eigentümer (`user_id`). +- Veröffentlichungs-Modal: Text von „wird bei Einreichung verbraucht" auf + „wird bei Veröffentlichung verbraucht; abgelehnte PMs kosten keinen Slot". + +**Tests:** Submit verbraucht keinen Slot; Publish (Admin, Auto-Publish, +Scheduler) verbraucht genau einen; Rot → kein Verbrauch; Archivieren + +erneutes Publizieren zählt nicht doppelt; Submit bei 0 Rest-Slots blockiert. + +### 9C · Submit-Gate-Schnittstelle + +**Ziel:** Das Gate aus dem Decision-Update §5.1, gebaut gegen eine schmale +Schnittstelle, die zunächst ein Stub bedient und in 9D/9E vom Tarif-Modul +implementiert wird — Modal und Service müssen dann nicht mehr angefasst werden. + +**Anpassungen:** + +- `User::hasActiveBooking(): bool` — Launch-Schnittstelle. Stub-Verhalten + über `config/billing.php` (`billing.enforce_booking`, Default `false`): + solange das Tarif-Modul fehlt, gibt die Methode `true` zurück; mit + aktiviertem Flag (und später echter Subscription-Prüfung) greift das Gate. +- Einreichungs-Modal (`press-release-submit-modal`): ohne aktive Buchung + zeigt das Modal statt des Prüf-Flows einen Buchungs-Hinweis mit CTA + („Buchung erforderlich" → Tarif-Seite). Der Button bleibt sichtbar. +- Server-Guard: `submitForReview()` wirft ohne aktive Buchung eine Exception + (UI allein reicht nicht); API-Submit-Route antwortet mit **402**. + +**Tests:** Gate aus (Default) → Verhalten unverändert; Gate an → +Modal-Hinweis statt Checkboxen, `submitForReview` wirft, API gibt 402. + +--- + +## 3. Block 2 — Tarif-Modul (nach Review-Stopp) + +### Entscheidung 12.06.2026 — Hybride Rechnungsarchitektur + +Alle **neuen** Abschlüsse und Zahlungen laufen über **Stripe**. Die Umsetzung +ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): + +| Kreis | Präfix | Inhalt | +|---|---|---| +| **Stripe-Shop** | `STR-` | Alles Neue (Abos, Einzel-PM, Credits) — komplette Abwicklung über Stripe, fortlaufende Nummer im STR-Kreis | +| **Manuell/Legacy** | `MAN-` | Laufende, noch aktive Alt-Zahlungen ab Relaunch: Fälligkeit wird im Hintergrund geprüft, Rechnung wie im Legacy-System ausgestellt | +| Alt-Archiv | — | Die importierten Alt-Rechnungen (`legacy_invoices`, 864 Stück) bleiben unverändert bestehen | + +### 9D · Tarif-Datenmodell — ✅ umgesetzt (12.06.2026) + +- **Cashier installiert** (`laravel/cashier` ^16.5, freigegeben); `User` ist + `Billable`, Cashier-Tabellen (`subscriptions`, `subscription_items`, + Customer-Spalten) migriert. Die lokale `invoices()`-Relation überschreibt + bewusst die Cashier-Methode. +- **`plans`**: Starter/Business/Pro/Agency mit Monats-/Jahrespreis + (Jahres = 10 × Monat), PM-Kontingent, Tageslimit, Stripe-IDs (nullable, + werden in 9E gepflegt). `PlanSeeder` idempotent. +- **`single_purchases`**: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit + Status Pending/Paid/Consumed/Refunded und Stripe-Checkout-Referenzen. +- **`invoice_number_sequences` + `InvoiceNumberGenerator`**: atomare, + fortlaufende Nummern pro Kreis (`STR-00001`, `MAN-00001`; Padding + konfigurierbar in `config/billing.php`). +- **MAN-Kreis**: `ManualInvoiceService` + Command + `billing:generate-manual-invoices` (Scheduler täglich 04:30) — findet + aktive/grandfathered `user_payment_options` **ohne** + `stripe_subscription_id` mit erreichtem `current_period_end`, friert die + Rechnungsadresse als Snapshot ein, stellt eine MAN-Rechnung aus + (Zahlungsziel `billing.manual_due_days`) und schaltet die Periode weiter. + Konditions-Overrides pro Vereinbarung über `legacy_conditions` + (`amount_cents`/`tax_cents`/`total_cents`/`interval`); ohne Override + Netto-Preis der `payment_option` + `billing.vat_rate`. Nicht abrechenbare + Fälle (fehlende Rechnungsadresse) werden geloggt und erneut versucht. +- **`User::hasActiveBooking()`** prüft jetzt echt (hinter + `billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf + ∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis). +- **USt-Behandlung (Einwand 12.06.)**: Alle neuen Preise sind **netto**. + `VatResolver` bestimmt die Steuer pro Rechnung aus der Rechnungsadresse: + DE immer mit Steuer, EU nur mit (formal plausibler) USt-ID befreit + (Reverse Charge inkl. Pflichthinweis in `invoices.tax_note`), Drittland + befreit. `vat_id` an `billing_addresses` + Snapshot, gepflegt über das + bestehende USt-ID-Feld im Profil. Grandfathered-Vereinbarungen rechnen + auf der Netto-Basis der letzten Legacy-Rechnung (`net_cents`, brutto ÷ + 1,19 bzw. Netto-Ausweis direkt). **Offen**: echte VIES-Validierung der + USt-ID (aktuell Formatprüfung) — Folgeschritt, vor Gate-Aktivierung + empfohlen. +- **Legacy-Migration (12.06.)**: `legacy:grandfather-subscriptions` leitet + die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem + Rechnungsarchiv ab und schreibt sie als `grandfathered` in + `user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz + vor dem Relaunch erneut). Details: + `dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`. +- ~~Noch offen in 9D~~ → mit 9E erledigt: Slot-Logik auf Plan-Kontingent + umgestellt, Stub-Spalte entfernt. + +### 9E · Stripe (Laravel Cashier) ✅ (12.06.2026) + +- ✅ Produkt-Sync: `billing:sync-stripe-plans` legt Tarife (Monats-/Jahres- + preise) und das Einzel-PM-Produkt als Netto-Preise in Stripe an + (Test-Mode gelaufen; IDs in `plans` bzw. `STRIPE_PRICE_SINGLE_PM`). +- ✅ Webhooks: `ProcessStripeWebhook` spiegelt bezahlte Stripe-Rechnungen + mit STR-Nummer in `invoices` und erfüllt Einmalkäufe; Endpoint + `https://pressekonto.com/stripe/webhook` registriert, Secret gesetzt. +- ✅ Checkout-Flows (Backend): `me.checkout.subscription` + + `me.checkout.single-pm` (CheckoutController → StripeCheckoutService); + Stripe Tax via `Cashier::calculateTaxes()` (Netto-Preise). UI-Anbindung + der Buttons folgt in 9F. +- ✅ Slot-Logik: Plan-Kontingent + Einmalkauf-Verbrauch statt Stub; + **Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz). + Details: `docs/user-admin/Billing-und-Rechnungskreise.md` §2. +- Offen → §7 der Billing-Doku: Stripe Tax im Dashboard aktivieren, + Live-Mode-Sync vor Relaunch. + +### 9F · Tarif-Seite + Checkout-UI ✅ (12.06.2026) + +- ✅ „Buchungen & Add-ons" zeigt das echte 4-Tier-Raster aus `plans` + (Monat/Jahr-Toggle, Jahrespreis als „2 Monate gratis") mit + Checkout-Buttons auf `me.checkout.subscription`; Einzel-PM als + separater No-Abo-Block (`me.checkout.single-pm`); Enterprise als + dezenter Hinweis unter dem Raster. Der Credit-Konzept-Mock ist + abgelöst (Credits → 9I bzw. Phase 2). +- ✅ Aktueller Tarif real: Abo (Preis, Kontingent, Kündigungsstatus), + Bestandstarif (unbegrenzt, nächste MAN-Rechnung) oder offene + Einzelkäufe; Kontingent-Kachel (`Unbegrenzt` bei Bestandsschutz). +- ✅ „Abo verwalten" → Stripe Billing Portal (`me.checkout.billing-portal`: + Zahlungsmethode, Rechnungen, Kündigung). +- ✅ Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung, + offene/eingelöste Einzelkäufe mit PM-Verknüpfung). +- Einstieg aus dem Submit-Gate-Hinweis (9C) führt bereits hierher. + +### 9G · Tageslimit + +- `plans.daily_limit` (Starter ohne Limit); Prüfung beim Veröffentlichen + (nicht beim Einreichen), zählt veröffentlichte PMs des Users pro Kalendertag + (Europe/Berlin); gilt auch für Extra-PMs. Überschreitung → PM bleibt in + `review` mit Hinweis, Veröffentlichung am Folgetag durch den Scheduler. + +### 9H · Einzel-PM + Abo-Brücke + +- Einzel-PM-Kauf 19 € → genau eine Einreichung/Veröffentlichung. +- Brücke: Abo-Abschluss innerhalb 30 Tagen rechnet 19 € auf den ersten + Monat an (Stripe-Coupon oder Rabatt-Position). + +### 9I · Launch-Credits + +- Credit-Wallet (1 Credit = 1 € Listenpreis, Pakete mit Volumenrabatt). +- Posten: **Extra-PM** (Kontingent voll → einzelne PM nachkaufen), + **Boost** (nur für grün klassifizierte PMs, nachträglich), + **Veröffentlichungsnachweis-PDF**. +- Kein Dofollow-Backlink-Verkauf (bewusst ausgeschlossen). + +### 9J · Abschluss + +- Volle Suite grün, Pint clean, `npm run build` clean. +- Doku-Sync: `STATUS-ABGLEICH`, `checkliste-user-backend.md`, dieses Dokument, + `PROGRESS.md`-Eintrag. + +--- + +## 4. Was außerhalb von Phase 9 bleibt + +- Vorab-KI-Prüfung, Redigieren/Re-Check-Loop, Prüfzähler, Credit-Overflow + (Decision-Update §7 — Phase 2) +- Score-Feinstufung für Boost („nur Geprüft/Hochwertig boostbar") +- Magic-Link-Flow, Statistik-/Abrechnungs-Tabs, Anhänge-Reaktivierung, + Trust-Score, Notice-and-Action + +--- + +## 5. Risiken & Annahmen + +- **Idempotenz Slot-Verbrauch (9B)**: Prüfung über Status-Logs statt neuem + Feld — bei Alt-Daten mit unvollständigen Logs schlimmstenfalls ein + doppelter Zähler; akzeptabel für den Stub, wird mit 9D-Periodenzähler + sauber. +- **Gate-Stub (9C)**: `enforce_booking=false` als Default hält das System + bis zum Tarif-Modul voll funktionsfähig; das Flag erlaubt Tests und + frühe Aktivierung. +- **9D/9E Datenmodell + Cashier**: größter Block, eigener Review-Stopp davor; + Stub-Ablösung (`press_release_quota`-Spalten entfernen) erst nach + verifizierter Migration. +- **Rechtstexte** (Einreichungs-Modal) sind weiterhin Platzhalter — + anwaltliche Prüfung läuft parallel, unabhängig von Phase 9. +- **Betrieb**: Queue-Worker für `classification` in Produktion bleibt + Go-Live-Voraussetzung (unabhängig von Phase 9). + +--- + +## 6. Akzeptanzkriterien Phase 9 gesamt + +- [ ] Gelb klassifizierte PMs gehen ohne manuelle Prüfung live (sofort/Termin) +- [ ] Rot verbraucht keinen Slot; Slot zählt genau einmal, bei Veröffentlichung +- [ ] Einreichen ohne aktive Buchung zeigt Buchungs-Hinweis (UI) und wird + serverseitig abgelehnt (Gate aktiviert) +- [ ] Tarife buchbar (4 Tiers, monatlich/jährlich), Einzel-PM kaufbar +- [ ] Tageslimit greift je Tier, auch für Extra-PMs +- [ ] Extra-PM, Boost (nur Grün) und PDF-Nachweis als Credits kaufbar +- [ ] Quota-Stub vollständig abgelöst, `pressReleaseQuotaRemaining()` stabil +- [ ] Tests grün, Pint clean, Build clean, Doku synchron diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..612663a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,88 @@ +# `docs/` — Konzept- und Status-Dokumente + +Stand: 12.06.2026 — Phase 8, die KI-Prüf-Pipeline (Phasen 0–5) sowie aus +Phase 9 die Stripe-Anbindung (9E), die Tarif-Seite (9F), das +Admin-Zahlungsmodul, die User-Panel-Restarbeiten und das +Verlinkungs-Decision-Update sind abgeschlossen. Nächste Blöcke: die zwei +neuen Decision-Updates in `weiteres/` (Phase-2-Funktionen & Magic-Link, +Duplicate-Content), dazu 9G Tageslimit und der Login-/Registrierungs-Flow. + +Diese README ist der schnellste Einstieg in den `docs/`-Ordner. +Sie verlinkt die zentralen Dokumente und sortiert sie nach „Was ist der aktuelle Stand?" vs. „Was ist konzeptueller Zielzustand?". + +## Schneller Einstieg + +| Frage | Doku | +|---|---| +| Was ist im Code, was ist Konzept, was fehlt? | [`STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md) | +| Was gilt für Preise, Kontingente und den Veröffentlichungs-Flow zum Launch? | [`Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) | +| Wie wird das umgesetzt (aktueller Plan)? | [`PHASE-9-FLOW-UND-TARIFE-PLAN.md`](./PHASE-9-FLOW-UND-TARIFE-PLAN.md) | +| Wie funktioniert die KI-Prüfung (Klassifikation, Score, Audit)? | [`user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`](./user-admin/Entwicklungsplan%20KI-Pruefung%20und%20Veroeffentlichung.md) | +| Was ist pro Phase erledigt, was offen? | [`user-admin/checkliste-user-backend.md`](./user-admin/checkliste-user-backend.md) | +| Welche Hub-Flux-Phasen sind durch? | [`../dev/frontend/hub-flux/PROGRESS.md`](../dev/frontend/hub-flux/PROGRESS.md) | + +## Aufbau + +### Top-Level — Status & Entscheidungen + +- [`STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md) — Konzept-vs-Code-Vergleich pro Page. **Erste Anlaufstelle.** +- [`Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) — **Verbindlicher Launch-Stand** für Tarife, PM-Kontingente, Credits und Veröffentlichungs-Flow. Überschreibt die Tarif-Abschnitte (§8–10) in `Konzept-Update 1` und im Relaunch-Konzept. +- [`PHASE-9-FLOW-UND-TARIFE-PLAN.md`](./PHASE-9-FLOW-UND-TARIFE-PLAN.md) — **Aktueller Umsetzungsplan**: Veröffentlichungs-Flow (9A–9C) + Tarif-Modul (9D–9J). +- [`PHASE-8-USER-PANEL-PLAN.md`](./PHASE-8-USER-PANEL-PLAN.md) — Plan der Phase 8 (abgeschlossen 29.05.2026, als Referenz erhalten). +- [`Echte öffentliche Unterseiten.md`](./Echte%20%C3%B6ffentliche%20Unterseiten.md) — Sitemap-Konzept, jede Seite mit IST-Notiz. +- [`KI-UND-ENTWICKLER-WORKFLOW.md`](./KI-UND-ENTWICKLER-WORKFLOW.md) — Workflow für KI-/Entwickler-Sessions. + +### `weiteres/` — Abgestimmte Decision-Updates (nächste Umsetzungsblöcke) + +- [`Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md`](./weiteres/Decision-Update%20Phase-2-Funktionen%20&%20Magic-Link-%C3%84nderungsprozess.md) — Boost + Veröffentlichungsnachweis (Launch), Magic-Link-Zugangs-/Änderungsprozess, Phase-2-Funktionen (Vorab-Prüfung, Prüfzähler, kostenpflichtige Änderungspfade). **Noch nicht umgesetzt.** +- [`Decision-Update Duplicate-Content & Duplicate-Checking.md`](./weiteres/Decision-Update%20Duplicate-Content%20&%20Duplicate-Checking.md) — Cross-Portal-Duplicate-Content (SEO) vs. Duplikat-Erkennung eingereichter PMs. **Noch nicht umgesetzt.** +- Das Decision-Update „Verlinkung & Backlinks" ist umgesetzt (systemseitige `rel`-Auszeichnung, `PressReleaseLinkPolicy`, 12.06.2026) und wurde nach der Integration in §4 des Preisstruktur-Decision-Updates entfernt — Umsetzungsdetails im Hub-Flux-PROGRESS-Log. + +### `user-admin/` — User-/Admin-Backend + +Konzept und Status-Dokumentation für das User- und Admin-Backend. + +- [`Admin-User.md`](./user-admin/Admin-User.md) — Hauptdokument zum User-/Admin-Backend (Navigation, Firmen-Detail, Rollen). +- [`Billing-und-Rechnungskreise.md`](./user-admin/Billing-und-Rechnungskreise.md) — **Zentrale Billing-Referenz**: hybride Rechnungskreise (STR-/MAN-/Archiv), Tarif-Datenmodell, USt-Regeln, Befehle, Konfiguration. +- [`checkliste-user-backend.md`](./user-admin/checkliste-user-backend.md) — Erledigt/Offen-Liste pro Phase (1, 7, 8, KI-Pipeline). +- [`Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`](./user-admin/Entwicklungsplan%20KI-Pruefung%20und%20Veroeffentlichung.md) — KI-Klassifikation (Rot/Gelb/Grün), Content-Score, Audit-Log; Phasen 0–5 umgesetzt (11.06.2026). +- [`Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`](./user-admin/Umsetzung%20Pressemitteilung%20Bearbeitung%20Titelbild%20Veroeffentlichung.md) — Umsetzungs-Notiz (11.06.2026): Titelbild/Cover, Lizenzformular, Zeitzonen-Handling, vereinfachte Veröffentlichungs-Box. +- [`Lizenztyp Bildupload.md`](./user-admin/Lizenztyp%20Bildupload.md) — Fachvorgabe für die Lizenz-/Rechtefelder beim Bildupload (umgesetzt). +- [`user-zusammenhaenge.md`](./user-admin/user-zusammenhaenge.md) — Datenmodell-Mapping, Models, Services und Commands. +- [`Presseportal – Konzept für Relaunch.md`](./user-admin/Presseportal%20%E2%80%93%20Konzept%20f%C3%BCr%20Relaunch.md) — Zielzustand der Plattform (KI-Workflow, Bilder, Notice-and-Action, DSGVO, Magic-Link, Tarife, Korrektur-Modell). Jeder Abschnitt hat eine **IST-Stand-Box**; die Tarif-Abschnitte sind durch das Decision-Update überschrieben. + +### `konzept/` — Strategie & Marke + +Strategische Konzepte und Updates. Sie beschreiben Themen, die teilweise oder noch gar nicht gebaut sind. Jedes Update hat oben einen IST-Stand-Hinweis. + +- [`Konzept Presseportal – Marktposition & Hebel.md`](./konzept/Konzept%20Presseportal%20%E2%80%93%20Marktposition%20&%20Hebel.md) — Marktanalyse und Positionierung (Anti-Zombie-Linie). +- [`Entwicklungs-Konzept - Frontend-Komponenten Multi-Brand.md`](./konzept/Entwicklungs-Konzept%20-%20Frontend-Komponenten%20Multi-Brand.md) — Multi-Brand-Architektur (umgesetzt). +- [`Konzept-Update 1 – Überarbeitete Abschnitte.md`](./konzept/Konzept-Update%201%20%E2%80%93%20%C3%9Cberarbeitete%20Abschnitte.md) — Tarife/Credits (§8–10 **überschrieben durch Decision-Update**), Score-Architektur §15 (Klassifikation + Content-Score umgesetzt, Trust-Score offen), Boost §16, Tool-Loop §17. +- [`Konzept-Update 2 – Score-Stufen-System.md`](./konzept/Konzept-Update%202%20%E2%80%93%20Score-Stufen-System.md) — Drei-Stufen-Score (Backend umgesetzt; öffentliche Badges im Web-Frontend offen). +- [`Konzept-Update 3 – Multi-Brand-Architektur (Hub & Spoke).md`](./konzept/Konzept-Update%203%20%E2%80%93%20Multi-Brand-Architektur%20(Hub%20&%20Spoke).md) — Hub-&-Spoke-Markenarchitektur. +- [`Konzept-Update 4 - Positionierung + Markenversprechen.md`](./konzept/Konzept-Update%204%20-%20Positionierung%20+%20%20Markenversprechen.md) — Positionierung und Markenversprechen. +- [`Konzept-X - Brand-Landing.md`](./konzept/Konzept-X%20-%20Brand-Landing.md) — Brand-Landing-Konzept. + +## Lesehilfe + +In den überarbeiteten Dokumenten finden sich folgende Markierungen: + +| Marker | Bedeutung | +|---|---| +| **IST-Stand JJJJ-MM-TT** | Kompakte Notiz oben am Abschnitt, was im Code tatsächlich umgesetzt ist. | +| Phase 1 / Phase 7 / Phase 8 | Abgeschlossene Roadmap-Blöcke: Grund-User-Backend, PM-Form-Refactor, User-Panel-Konsolidierung. | +| KI-Pipeline (Phasen 0–5) | Klassifikation, Routing, Content-Score — abgeschlossen 11.06.2026, siehe Entwicklungsplan. | +| Hub-Flux | Visuelle Migrationsphase des User Backends, gepflegt in `dev/frontend/hub-flux/`. | +| Phase 2 / Phase 3 | Später — Magic-Link-Flow, Re-Check/Redigieren, Trust-Score, Notice-and-Action, Statistik. | +| Launch-Block (offen) | Zahlung/Tarife, Submit-Gate, Slot-Verbrauch bei Veröffentlichung — siehe Decision-Update. | + +## Wie pflegen wir die Doku? + +- Wenn sich der Code so weit ändert, dass ein Konzept-Abschnitt nicht mehr stimmt, kommt eine **IST-Stand-Box** an den Abschnitt, statt den Konzept-Text zu löschen. So bleibt die ursprüngliche Zielvorstellung lesbar. +- Entscheidungen, die frühere Konzept-Festlegungen ersetzen, kommen als eigenes **Decision-Update** auf Top-Level; die überschriebenen Abschnitte bekommen einen Verweis darauf. +- Jeder Phasen-Abschluss aktualisiert + - `user-admin/checkliste-user-backend.md` (Erledigt-Block), + - `STATUS-ABGLEICH-USER-PANEL.md` (Abgleich), + - `dev/frontend/hub-flux/PROGRESS.md` (Tagebuch), + - und ggf. die Detail-Doku in `dev/frontend/hub-flux/`. +- Neue große Themen bekommen ein eigenes Plan-Dokument auf `docs/`-Top-Level (z. B. `PHASE-8-USER-PANEL-PLAN.md`). diff --git a/docs/STATUS-ABGLEICH-USER-PANEL.md b/docs/STATUS-ABGLEICH-USER-PANEL.md new file mode 100644 index 0000000..3b2463b --- /dev/null +++ b/docs/STATUS-ABGLEICH-USER-PANEL.md @@ -0,0 +1,342 @@ +# Status-Abgleich · User Panel + +Stand: 2026-06-11 (Phase 8 vollständig abgeschlossen; KI-Prüf-Pipeline +Phasen 0–5 umgesetzt; Titelbild-/Lizenz-/Zeitzonen-Umbau vom 10./11.06. +eingearbeitet. Preise & Veröffentlichungs-Flow: siehe +[`Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md)) + +> Dieses Dokument vergleicht die Konzept-Dokumente im Ordner `docs/` mit dem +> tatsächlichen Code-Stand. Es dient als Single Source of Truth für die +> Entscheidung, welche Konzept-Abschnitte aktualisiert werden müssen und wo +> weiterhin Lücken bestehen. +> +> **Methode**: Code-Inspektion aller Customer-Komponenten in +> `resources/views/livewire/customer/`, Models, Migrationen und Services +> gegen die Inhalte der Doku in `docs/user-admin/` und `docs/konzept/`. +> +> **Lese-Hilfe**: +> +> - ✅ **Doku stimmt mit Code überein** — kein Handlungsbedarf +> - 🔄 **Doku ist überholt** — Code ist schon weiter, Doku muss nachgezogen werden +> - 📝 **Code ist hinter Doku** — Konzept beschreibt etwas, das noch nicht +> gebaut ist +> - ⚠️ **Inkonsistent** — Doku und Code widersprechen sich +> - ❓ **Nicht im Konzept** — im Code da, aber nirgends dokumentiert + +--- + +## 1. Globale Architektur des User Backends + +| Konzept-Aussage | Quelle | IST im Code | Status | +|---|---|---|---| +| User/Admin getrennt, technisch gemeinsames Backend, Trennung über Rollen/Policies | `Admin-User.md` | Umgesetzt: `PressReleasePolicy`, Spatie-Rollen, Customer-/Admin-Routen | ✅ | +| Navigation in „Mein Bereich · Finanzen · Konto" | `Admin-User.md`, `checkliste-user-backend.md` | `components/layouts/app/sidebar.blade.php` setzt die drei Gruppen | ✅ | +| Topbar oben rechts mit Firmen-Kontext-Switcher | `Admin-User.md` | `customer/company-switcher.blade.php` + Layout-Integration | ✅ | +| „Pressemappen" terminologisch auf „Firmen" umbenannt | `checkliste-user-backend.md` | Im UI durchgehend „Firmen" / „Meine Firmen"; Routen heißen aus Legacy-Gründen weiterhin `me.press-kits.*` | 🔄 (Doku-Nacharbeit: Hinweis ergänzen, dass die internen Route-Namen weiterhin `press-kits` heißen) | +| Phase 1 funktional abgeschlossen | `checkliste-user-backend.md` Z. 71 | Trifft zu — alle in „Erledigt" markierten Punkte sind im Code verifizierbar | ✅ | + +--- + +## 2. Customer-Pages — IST-Stand pro Page + +### Dashboard (`customer/dashboard.blade.php`) + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Datenqualitäts-Hinweise (Profil, Rechnungsadresse, Pressekontakte, PMs ohne Firma) | umgesetzt mit `` | ✅ | +| KPI-Reihe Pressemitteilungen | umgesetzt mit ``, Trend-Slot mit `pub/prüf/entwurf` | ✅ | +| Filter-Reaktion auf Firmen-Kontext | `recent` und `companies` queries respektieren `selectedCompany` | ✅ | + +### Pressemitteilungen-Liste (`customer/press-releases/index.blade.php`) + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Status-Tabs (Alle/Veröffentlicht/Entwürfe/Prüfung/Abgelehnt/Archiv) | umgesetzt als `view-tabs` mit Counter-Pillen | ✅ | +| Filter ohne Firma, Status, Portal | `statusFilter`, `portalFilter`, `companyFilter` aktiv | ✅ | +| Filter-Presets (`user_filter_presets`) | **fehlt** | 📝 (in Phase 2 lt. Doku — bleibt pending) | +| PM-Detail Tab „Verlauf" aus `press_release_status_logs` | als „Status & Verlauf"-Card eingebaut, nicht als eigener Tab | 🔄 (Doku-Anpassung: Card statt Tab; funktional gleichwertig) | +| Hinweis Scheduling/Embargo in der Liste | umgesetzt (Sub-Label „geplant · …" / „Embargo bis …" in der Datums-Spalte, `index.blade.php` Z. 505–514) | ✅ (Phase 8B) | + +### Pressemitteilungs-Forms + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Einfacher Editor mit Absätzen, fett + kursiv | Flux-Editor mit `heading | bold italic | bullet ordered blockquote | link` — mehr als minimal | 🔄 (Konzept-Update: Aktuelle Toolbar ist bewusst etwas größer als „nur fett + kursiv") | +| Pflichtfeld `company_id` für Customer | Validation `required` | ✅ | +| Portal aus Firma abgeleitet (Customer) | `updatedCompanyId()` setzt `portal` aus `company->portal` | ✅ | +| `subtitle`-Feld | seit Phase 7 da | ❓ (im Konzept nicht erwähnt, aber sinnvoll) | +| `scheduled_at`, `embargo_at`-Felder im Form | `scheduled_at` da (Datum via `flux:date-picker` + Uhrzeit via `flux:time-picker`, Eingabe/Anzeige in Europe/Berlin, Speicherung UTC). **Embargo aus der Form-UI entfernt** (11.06.) — `embargo_at` bleibt im Schema, wird beim Speichern auf `null` geführt | 🔄 (bewusste Vereinfachung; Doku: `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`) | +| Titelbild pro PM | ein einzelnes Cover-Bild (1280×580) oder SVG-Platzhalter; Upload-Form einklappbar, Platzhalter-Picker | ✅ (Phase 8F/8G + Umbau 11.06.) | +| Einreichungs-Modal in allen Customer-Ansichten | `confirm-submit-review`-Modal in Show, Create **und** Edit (KI-Plan Phase 0) | ✅ | +| HTML-Sanitizer auf Save | `PressReleaseHtmlSanitizer` (mews/purifier) | ❓ (Konzept-Punkt 2/Bilder nennt KI-Check, aber keinen HTML-Sanitizer — sollte dokumentiert werden) | +| Boilerplate-Override pro PM | seit Phase 7 als optionaler Override-Text | ❓ (im Konzept nicht erwähnt) | +| Pressekontakt-Zuordnung Single-Select (1 pro PM, n:m beibehalten) | seit Phase 7, jetzt optional; Warn-Box in der Sidebar-Card, wenn kein Kontakt gewaehlt (`create`/`edit.blade.php`) | ✅ (Phase 8C; Konzept-Punkt: war ursprünglich „mehrere möglich", jetzt 1 pro PM optional) | +| Attachment-Manager | **temporär deaktiviert wegen Security-Review** | ⚠️ (Konzept beschreibt Anhänge, Code hat es auskommentiert) | + +### Pressemitteilungs-Detail (Customer Show) + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Status & Verlauf inkl. Logs | umgesetzt | ✅ | +| Zugeordnete Pressekontakte | umgesetzt | ✅ | +| Rejection-Begründung sichtbar | umgesetzt | ✅ | +| Vorschau-Link für externe Reviewer | `generateShareLink` via Magic-Link-Token | ✅ | +| Anzeige Subtitle / `scheduled_at` / `embargo_at` / `boilerplate_override` / `no_export` | umgesetzt — Subtitle unter H1, Scheduling/Embargo als Cards im „Status & Verlauf"-Block, Boilerplate-Override als eigene Card (`customer/press-releases/show.blade.php`) | ✅ (Phase 8A; Admin-Show analog) | + +### Firmen-Liste (`customer/press-kits/index.blade.php`) + +| Konzept-Aussage / Mockup | IST | Status | +|---|---|---| +| Karten-Grid pro Firma mit Logo, Status, Portal, Rolle, KPIs | umgesetzt auf Mockup-Niveau (`.firm-card`, deterministische Logo-Varianten, Status-Badge, Portal-Pills, Rolle-Pill, KPIs) | ✅ (Phase 8E) | +| Counter-Strip (X Firmen, X aktiv, X PMs total, X Kontakte) | umgesetzt | ✅ (Phase 8E) | +| Saved-View-Tabs (Alle / Aktiv / In Anlage / Inaktiv / Mit mir geteilt) | umgesetzt mit Live-Counts; „In Anlage" bewusst noch leer (Phase-2-Heuristik) | ✅ (Phase 8E) | +| Filter-Chips (Status / Portal / Rolle / Branche) | Portal + Rolle via FluxUI-Dropdown + URL-Sync umgesetzt | ✅ (Phase 8E) | +| Seg-Toggle Karten/Liste | umgesetzt (`?mode=list`) | ✅ (Phase 8E) | +| Empty-States (keine Firma / Filter ohne Treffer) | umgesetzt (3-Schritt-Onboarding + Reset-CTA) | ✅ (Phase 8E) | +| Rollen-Legende (Admin / Redakteur / Beobachter) | umgesetzt als `panel-warm` | ✅ (Phase 8E) | +| „Firma anlegen"-CTA | zeigt „Firma anlegen anfragen" → Profil (Add-Tile); Self-Service-Anlage bleibt Phase-2-Thema | 🔄 (bewusst, Firma-Self-Service ist Phase 2) | + +### Firmen-Detail (`customer/press-kits/show.blade.php`) + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Tabs Übersicht / Stammdaten / Pressekontakte / PMs / Statistik / Abrechnung | **eine lange Seite mit Quick-Nav-Anker, keine echten Tabs** | 🔄 (Doku-Update: Quick-Nav statt Tabs; visuell gleichwertig) | +| Stammdaten inkl. Logo bearbeitbar | umgesetzt | ✅ | +| Pressekontakte verwalten | umgesetzt | ✅ | +| Eigentümer-Anzeige konsolidiert aus `owner_user_id` + `company_user.role` | umgesetzt | ✅ | +| Statistik-Tab | nur Stub („In Vorbereitung") | 📝 (Phase 2) | +| Abrechnung-Tab | nur Stub | 📝 (Phase 2) | +| Magic-Link aktiv/inaktiv-Badge pro Kontakt | **fehlt** | 📝 | +| Anzahl PMs pro Kontakt aus `press_release_contact` | **fehlt** | 📝 | +| „+ Neuer Pressekontakt" mit Magic-Link-Berechtigung-Toggle | nur Basis-Form, kein Magic-Link-Toggle | 📝 (gehört zu Magic-Link-Flow → Phase 2) | + +### Settings (`settings/*`, `customer/profile`, `customer/security`) + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Profil + Rechnungsadresse + Sicherheit + Newsletter + API-Tokens | alles vorhanden | ✅ | +| Magic-Link-Verlauf in Sicherheit | **fehlt** | 📝 (Phase 2) | +| API-Nutzungs-Log | **fehlt** | 📝 (Phase 2) | +| Team-Tab (Agency-Tarif) | **fehlt** | 📝 (Phase 2) | + +### Finanzen + +Zentrale Billing-Referenz: [`user-admin/Billing-und-Rechnungskreise.md`](./user-admin/Billing-und-Rechnungskreise.md). + +| Konzept-Aussage | IST | Status | +|---|---|---| +| Rechnungen mit Legacy-Archiv | umgesetzt | ✅ | +| Hybride Rechnungskreise STR-/MAN- (Decision 12.06.) | umgesetzt (Phase 9D) — Nummern-Generator, MAN-Fälligkeitslauf, Grandfather-Migration, USt-Logik (`VatResolver`) | ✅ | +| Tarif-Datenmodell + Cashier | umgesetzt (Phase 9D) — `plans`, `single_purchases`, `User` ist Billable | ✅ | +| Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ | +| Buchungen & Add-ons (UI) | umgesetzt (Phase 9F) — Tarif-Raster, Einzel-PM-Block, Bestandstarife, Billing Portal | ✅ | +| Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) | + +--- + +## 3. Großthemen aus dem Konzept — Status + +### 3.1 KI-Freigabe-Workflow + +**Konzept-Stand**: `Presseportal – Konzept für Relaunch.md` Abschnitt 1 + §15. +**Umsetzungs-Doku**: `user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md` (Phasen 0–5 ✅, 11.06.2026). + +| Punkt | Code-Stand | +|---|---| +| KI-Prüfung mit JSON-Antwort | **umgesetzt** — `ClassifyPressRelease`-Job (Queue `classification`), OpenAI-Treiber + deterministischer Fallback, provider-agnostische Architektur unter `app/Services/PressRelease/Classification/` | +| Drei-Stufen-Ergebnis grün/gelb/rot | **umgesetzt** — `press_releases.classification`; Routing (seit Phase 9A, Entscheidung 12.06.2026): Rot → `rejected` + Mail, **Gelb/Grün → Auto-Publish** (sofort/zum Termin); unklassifizierte PMs bleiben als Fallback in der manuellen Queue | +| Logging der KI-Antworten | **umgesetzt** — `ki_audits`-Tabelle (append-only, inkl. Provider/Modell/Begründung/Raw-Response) | +| Content-Score 0–100 → Stufe | **umgesetzt** — `content_score`/`content_tier` (`ScorePressRelease`-Job), Editor-Panel, Admin-Badges, öffentliches Stufen-Badge in Customer-Show | +| Re-Klassifikation bei Änderung | **umgesetzt** — `reclassifyIfClassified()`/`rescoreIfScored()` bei Titel-/Text-Änderung (Customer, Admin, API) | +| Admin-On-Demand-Prüfung | **umgesetzt** — „Prüfung"-Button + Modal im Admin-Editor (Re-Check ohne Statusänderung) | +| API-Absicherung | **umgesetzt** — API kann `status` nicht mehr setzen; eigene Submit-Route läuft durch denselben Funnel | +| Trust-Score | **fehlt** (Phase 3 / KI-Plan Phase 6) | +| Blacklist-Wort-Check | **vorhanden** als synchroner Hard-Filter vor der KI-Klassifikation | + +**Bewertung**: ✅ — Kern-Pipeline produktionsbereit. ⚠️ Betriebs-Voraussetzung: +Queue-Worker für `classification` in Produktion (Test-Drain: +`php artisan classification:work`). 📝 verbleibend: Trust-Score, Live-Update +des Ergebnisses in der UI (aktuell erst nach Reload sichtbar), Stufen-Badges +im öffentlichen Web-Frontend. + +### 3.2 Bilder & Lizenzen + +**Konzept-Stand**: `Presseportal – Konzept für Relaunch.md` Abschnitt 2 + `Admin-User.md` Punkt 4. + +| Punkt | Code-Stand | +|---|---| +| Upload-Workflow (Eigenes / Stock / KI) | Nur „Eigenes Bild" via `press-release-images-manager`; auf **ein Titelbild pro PM** begrenzt, Cover-Variante 1280×580, Original wird nach Variantenerzeugung gelöscht (Stock/KI weiterhin offen) | +| Pflichtfelder (Urheber, Lizenztyp, Lizenz-URL, Personen-Einwilligung, Rechte-Bestätigung) | umgesetzt (Phase 8H, erweitert 10./11.06. nach `Lizenztyp Bildupload.md`) — `author`, `license_type` (7 Typen, `App\Enums\ImageLicenseType`), `license_detail`, `license_url` (bedingt Pflicht), `source_url`, `people_rights_status`, `property_rights_status`, `rights_notes`, `rights_confirmed_at`; Risikohinweise bei unklaren Lizenzfällen | +| KI-Wasserzeichen-Check | **fehlt** | +| Unsplash/Pexels-API | **fehlt** | +| KI-Bildgenerierung | **fehlt** | +| `is_preview`-Flag für Titelbild | im Modell vorhanden, im Manager toggelbar | +| Bild-Varianten (thumb/medium/large) | `ImageService::PRESS_RELEASE_IMAGE_VARIANTS` generiert sie automatisch | +| SVG-Platzhalter, falls keine Bilder | umgesetzt (Phase 8F/8G) — zentrales Set in `public/images/press-release-placeholders/`, `App\Enums\PressReleasePlaceholder`, `PressReleaseCoverImage`-Resolver, Hero in Customer-/Admin-Show | + +**Bewertung**: ✅ für Lizenzfelder + SVG-Platzhalter (Phase 8). 📝 verbleibend: Stock-/KI-Quellen, Wasserzeichen-Check (Phase 2/3). + +### 3.3 Notice-and-Action (Meldung durch Dritte) + +**Konzept-Stand**: `Presseportal – Konzept für Relaunch.md` Abschnitt 3. + +| Punkt | Code-Stand | +|---|---| +| Öffentliches Melden-Formular | **fehlt** | +| Ticketsystem mit Kategorien (Urheberrecht, Persönlichkeitsrecht, …) | **fehlt** | +| KI-Triage | **fehlt** | +| Quarantäne-Flow | **fehlt** | + +**Bewertung**: 📝 — Phase 2/3-Thema, im Konzept gut beschrieben. + +### 3.4 Magic-Link-Flow für Pressekontakte + +**Konzept-Stand**: `Presseportal – Konzept für Relaunch.md` Abschnitt 6. + +| Punkt | Code-Stand | +|---|---| +| `magic_links`-Tabelle | **vorhanden** | +| Magic-Link-Generator | `MagicLinkGenerator` existiert (wird für PM-Vorschau-Links genutzt) | +| Magic-Link für Pressekontakt-Zugang | **fehlt** als eigener Flow | +| Token-Tabelle `press_release_access_requests` o. ä. | **fehlt** | +| Änderungs-Wizard (Tippfehler/Daten/Korrektur/Update/DSGVO) | **fehlt** | + +**Bewertung**: 📝 — Phase 2-Thema, vollständig im Konzept beschrieben. + +### 3.5 Pricing / Tarife / Credits + +**Verbindlicher Konzept-Stand**: [`Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) +(11.06.2026 — überschreibt §8–10 in `Konzept-Update 1` und im Relaunch-Konzept). + +| Punkt (laut Decision-Update) | Code-Stand | +|---|---| +| Tarif-Raster Starter/Business/Pro/Agency (29/49/99/199 €, 3/10/25/60 PMs) | **nicht im Datenmodell** | +| Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** | +| Zahlung/Checkout (Stripe) | **Backend umgesetzt** (Phase 9E) — Checkout-Routen, Webhooks, STR-Spiegelung; UI folgt mit 9F | +| Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | **umgesetzt** (Phase 9B) — zählt idempotent beim ersten `published`-Übergang; Einreichen erfordert freien Slot, verbraucht aber keinen | +| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung seit 9D/9E (Abo ∨ Einmalkauf ∨ Legacy-Vereinbarung) | +| Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** | +| Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** | +| Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI | +| `user_payment_options`-Tabelle | **vorhanden** (Pivot zu Companies da, aber kein aktiver Flow) | + +**Bewertung**: 📝 — der **Launch-Block** und damit das größte ungebaute Feature. +Vorhandene Anschlusspunkte: Plan-Kontingent (`pressReleaseQuotaRemaining()`, +null = unbegrenzt/Bestandsschutz), Veröffentlichungs-Modal (zeigt Kontingent), +KI-Klassifikation (liefert das Rot/Gelb/Grün für den Slot-Verbrauch). +Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler +(alles Phase 2, siehe Decision-Update §7). + +### 3.6 Korrektur-Modell & Tombstones + +**Konzept-Stand**: `Presseportal – Konzept für Relaunch.md` Abschnitt 4. + +| Punkt | Code-Stand | +|---|---| +| Korrektur-Hinweis | **fehlt** | +| Update-Hinweis (am Ende anhängen) | **fehlt** | +| Anonymisierung (DSGVO) | **fehlt** | +| Tombstone statt Hard-Delete | `PressReleaseService::deleteFromAdmin()` setzt veröffentlichte PMs auf „archiviert" mit Ersatztext — **rudimentär da** | +| Textvorlagen admin-pflegbar | **fehlt** | + +**Bewertung**: 🔄 — Tombstone-Variante existiert minimal; Konzept-Doku sollte den Ist-Stand notieren, der Rest ist Phase 2. + +### 3.7 Score / Trust-Score (Konzept-Update 2) + +**Konzept-Stand**: `Konzept-Update 2 – Score-Stufen-System.md`. + +| Punkt | Code-Stand | +|---|---| +| Content-Score 0–100 → Stufe (Standard/Geprüft/Hochwertig) | **umgesetzt** (KI-Plan Phase 5) — `content_score`/`content_tier`, Schwellen kalibrierbar in `config/scoring.php` (Geprüft ≥ 60, Hochwertig ≥ 80) | +| Stufen-Badges im Backend + Customer-Ansicht | **umgesetzt** (Standard wird laut Konzept nicht beworben) | +| Stufen-Badges im öffentlichen Web-Frontend | **fehlt** (Folgearbeit) | +| Trust-Score (User-/Firmen-Ebene) | **fehlt** (Phase 3 / KI-Plan Phase 6) | +| Auto-Publishing in Abhängigkeit vom Score | Klassifikations-basiert umgesetzt (Grün → Auto-Publish); Trust-Score-Lockerung fehlt | +| Boost-Eligibilität nach Stufe | **fehlt** — zum Launch gilt laut Decision-Update: Boost nur für **grüne** PMs, Score-Feinstufung ist Phase 2 | + +**Bewertung**: ✅ Content-Score-Kern da; 📝 Trust-Score + öffentliche Badges + Boost-Stufung. + +--- + +## 4. Was im Code da ist, aber im Konzept nicht / nur am Rande steht + +| Feature | Wo im Code | Doku-Nacharbeit | +|---|---|---| +| Phase-7-Schema-Erweiterungen (`press_releases.subtitle`, `scheduled_at`, `embargo_at`, `boilerplate_override`, `no_export`) | Migrationen `2026_05_20_*` | Im Konzept ergänzen, dass PMs Untertitel + Scheduling/Embargo unterstützen | +| `mews/purifier` für HTML-Sanitization | `PressReleaseHtmlSanitizer` | Im Konzept-Abschnitt zu Editor erwähnen | +| `press_release_attachments`-Tabelle + Model | Migration `2026_05_20_143424_*` | UI auskommentiert, Tabelle bleibt → Doku-Anker für spätere Reaktivierung | +| Background-Job für scheduled publishing | `app/Console/Commands/PublishScheduledPressReleases.php`, alle 5 Min via Scheduler; publiziert seit der KI-Anbindung nur noch **grün klassifizierte** fällige PMs | Im Konzept als „automatische Veröffentlichung zum geplanten Termin" hinzufügen | +| Zeitzonen-Handling für geplante Termine | `PressRelease::DISPLAY_TIMEZONE` (Europe/Berlin), `scheduledAtLocal()`/`embargoAtLocal()`; Eingabe Berlin, Speicherung UTC | dokumentiert in `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`; `published_at`/`created_at` weiterhin UTC-Anzeige (Folgeschritt) | +| Monatlicher Quota-Reset | `press-releases:reset-monthly-quota` (Scheduler, 1. des Monats) | Setzt den Plan-Kontingent-Zähler zurück (seit 9E) | +| FluxUI Toast für UX-Feedback | `Flux::toast()` durchgehend in Customer-Forms | Konzept-übergreifend, kein Konzept-Update nötig | +| Smooth-Scroll zu Validation-Errors | `resources/js/portal-form-hooks.js` | UX-Detail, keine Konzept-Doku | +| Pre-Submit-Check-Liste in PM-Forms | computed `presubmitChecks` | Im Konzept als „Pre-Submit-Check senkt Support-Aufwand" ergänzen | +| Hub-Design-System (Tokens + Komponenten) | `dev/frontend/hub-flux/` (Phase 0–7) | Eigene Roadmap-Doku, nicht teil von `docs/` | +| Theme-Override pro Domain | `ThemeServiceProvider` + `config/domains.php` | In `Echte öffentliche Unterseiten.md` ergänzen | +| Public-Detail-Page (`web/release-detail.blade.php`) | umgesetzt | In `Echte öffentliche Unterseiten.md` als „existiert" markieren | + +--- + +## 5. Offene Punkte aus dem letzten Code-Review + +Diese Punkte habe ich beim Review der Phase-7-Forms gefunden, sie sind weder +in den Konzept-Dokumenten erfasst noch in einem Plan: + +| Lücke | Betroffene Dateien | Status | +|---|---|---| +| Customer-Show zeigt weder `subtitle` noch `scheduled_at`/`embargo_at`/`boilerplate_override` | `customer/press-releases/show.blade.php` | ✅ erledigt (8A) | +| Admin-Show zeigt weder `subtitle` noch `boilerplate_override` | `admin/press-releases/show.blade.php` | ✅ erledigt (8A) | +| Liste-Indikator für Scheduling/Embargo | `customer/press-releases/index.blade.php`, `admin/press-releases/index.blade.php` | ✅ erledigt (8B) | +| Pressekontakt-Sidebar zeigt keine Warn-Box, wenn kein Kontakt gewählt | `customer/press-releases/create.blade.php`, `edit.blade.php` | ✅ erledigt (8C) | +| Anhang-Tests laufen ins Leere | `tests/Feature/PressReleaseAttachmentsManagerTest.php`, Teile von `PressReleasePhase7SchemaTest.php` | ✅ via `->skip(...)` mit Verweis auf Security-Review | +| Roadmap-Doku `19-PHASE-7-PRESS-RELEASE-FORM.md` ist nicht mehr aktuell | Letzte 3 große Änderungen fehlen | offen → wird im Phase-8-Abschluss (8K) als `20-PHASE-8-…md` dokumentiert | + +--- + +## 6. Empfehlungen zur Pflege der Doku + +### 6.1 Sofort ohne Risiko machbar + +1. In `Admin-User.md` ergänzen: „PMs unterstützen Untertitel, Scheduling und + Embargo seit Phase 7". +2. In `Presseportal – Konzept für Relaunch.md` Abschnitt 1: aktuellen + Blacklist-Stand notieren („KI-Vorprüfung folgt; aktuell wird per + Blacklist gegen offensichtliche Verstöße geprüft"). +3. In `Presseportal – Konzept für Relaunch.md` Abschnitt 2: hinzufügen, + dass Bilder aktuell nur als „Eigenes Bild" hochgeladen werden können, + Stock- und KI-Quellen folgen. +4. In `Presseportal – Konzept für Relaunch.md` Abschnitt 4: notieren, dass + Tombstone-Variante rudimentär da ist (`deleteFromAdmin`-Ersatztext), + die Korrektur-/Update-Hinweise aber noch fehlen. +5. In `checkliste-user-backend.md` neuen Block „Phase 7" hinzufügen mit + Verweis auf `dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md`. + +### 6.2 Mit Phase 8 ergänzen + +6. Neuer Abschnitt im `Admin-User.md`: „Titelbild & SVG-Platzhalter". +7. Neuer Abschnitt im `Presseportal – Konzept für Relaunch.md`: „Veröffentlichungs-Modal & Quota-Kommunikation". +8. Aktualisierung der Firmen-Liste-Doku im `Admin-User.md` mit den + neuen UI-Bausteinen (Counter-Strip, Saved-Views, Filter-Chips, + Card/List-Toggle, Rollen-Legende). + +### 6.3 Längerfristig (Phase 2/3) + +9. Magic-Link-Flow für Pressekontakte → eigenes Doku-Kapitel, sobald + gebaut. +10. Tarif-/Credit-System → eigener Architektur-Block (Datenmodell, + Stripe-Integration, Quota-Counter-Implementierung). + +--- + +## 7. Quellen-Übersicht für die nächsten Schritte + +| Frage | Quelle | +|---|---| +| Was ist konzeptuell der User-Backend-Aufbau? | `docs/user-admin/Admin-User.md` | +| Was ist bereits umgesetzt, was offen? | `docs/user-admin/checkliste-user-backend.md` (Phase 1 ✅) | +| Datenmodell-Übersicht? | `docs/user-admin/user-zusammenhaenge.md` | +| Großthemen-Konzept (KI, Bilder, Tombstones, Magic-Link, Pricing)? | `docs/user-admin/Presseportal – Konzept für Relaunch.md` | +| Brand- & Design-System? | `docs/konzept/Entwicklungs-Konzept - Frontend-Komponenten Multi-Brand.md`, `dev/frontend/hub-flux/*` | +| Score-System? | `docs/konzept/Konzept-Update 2 – Score-Stufen-System.md` | +| Aktuelle Phase 7 (PM-Form-Refactor)? | `dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md` | +| Nächste Phase 8? | `docs/PHASE-8-USER-PANEL-PLAN.md` (neu, neben diesem Dokument) | diff --git a/docs/konzept/Entwicklungs-Konzept - Frontend-Komponenten Multi-Brand.md b/docs/konzept/Entwicklungs-Konzept - Frontend-Komponenten Multi-Brand.md new file mode 100644 index 0000000..27e5455 --- /dev/null +++ b/docs/konzept/Entwicklungs-Konzept - Frontend-Komponenten Multi-Brand.md @@ -0,0 +1,509 @@ + + +**Datum:** 12. Mai 2026 **Status:** Technisches Implementierungs-Konzept **Tech-Stack:** Laravel 12+, Livewire 4 / Volt, Tailwind CSS (v4), Alpine.js (über Livewire) **Bezug:** Konzept-Update 3 (Multi-Brand-Architektur), Konzept-Update 4 (Positionierung), Brand-Landing-Konzept businessportal24 + +> **IST-Stand 21.05.2026**: Multi-Brand-Architektur ist umgesetzt +> (`config/domains.php`, `ThemeServiceProvider`, getrennte Vite-Builds +> `portal` + `web`). Die Hub-Migration des User Backends ist als +> eigene Roadmap in `dev/frontend/hub-flux/` dokumentiert (Phasen 0–7 +> abgeschlossen, Phase 8 in Planung). Der hier beschriebene Brand-Context +> wird ueber `View::share()` global aufgeloest. + +--- + +## 1. Leitprinzipien + +Vier Regeln, an denen sich jede technische Entscheidung in diesem Dokument messen muss: + +1. **Ein Codebase, viele Brands.** Kein Branch pro Portal, keine duplizierten Views. Differenzierung über Konfiguration, CSS-Variablen und gezielte View-Overrides. +2. **Brand-Awareness zentral aufgelöst, nicht in Komponenten verteilt.** Eine Komponente fragt nicht „bin ich auf businessportal24?". Sie konsumiert eine `$brand`-Context-Variable und rendert entsprechend. +3. **Livewire/Volt nur wo nötig.** Statische Komponenten bleiben pures Blade. Reaktivität ist ein Kostenfaktor (Server-Roundtrips, Hydration, State-Management) – sie muss verdient werden. +4. **Solo-tauglich heißt: jede Entscheidung muss in 6 Monaten noch verständlich sein.** Lieber explizit als clever. + +## 2. Brand-Auflösung (Multi-Tenant-Pattern) + +### Brand-Resolution-Pipeline + +``` +Request → Middleware → BrandResolver → Brand-Context im Container + → View-Pfad-Override + → Config-Override + → Layout-Auswahl +``` + +**Schritt 1: Domain-Mapping** + +Die `brands`-Tabelle aus Update 3 enthält pro Brand mindestens: + +- `slug` (z.B. `businessportal24`, `presseecho`, `hub`) +- `primary_domain` (z.B. `businessportal24.com`) +- `theme_key` (z.B. `bp24`, `pe`) – Verweis auf CSS-Token-Set +- `config_path` (z.B. `brands/businessportal24.php`) +- `is_publisher_hub` (boolean) +-ist zu prüfen, teils schon im System angelegt! + +**Schritt 2: Middleware** + +```php +// app/Http/Middleware/ResolveBrand.php +public function handle(Request $request, Closure $next): Response +{ + $brand = Cache::rememberForever( + "brand.domain.{$request->getHost()}", + fn() => Brand::query() + ->where('primary_domain', $request->getHost()) + ->orWhereJsonContains('aliases', $request->getHost()) + ->firstOrFail() + ); + + app()->instance(Brand::class, $brand); + View::share('brand', $brand); + Config::set('brand', $brand->config()); + + return $next($request); +} +``` + +Cache ist hier wichtig – die Domain-zu-Brand-Auflösung passiert bei jedem Request. `rememberForever` mit explizitem Cache-Bust beim Brand-Update. +-ist zu prüfen, teils schon im System angelegt! + +**Schritt 3: Brand im Container** + +Jede Klasse kann via Dependency Injection auf die aktuelle Brand zugreifen: + +```php +public function __construct(private Brand $brand) {} +``` + +In Blade-Templates ist `$brand` durch `View::share()` direkt verfügbar. + +### Lokale Entwicklung + +Lokal arbeiten mit `.test`-Domains in Docker (devserver) auf dem Server via Treafik: + +- `businessportal24.test` +- `presseecho.test` +- `pressekonto.test` + +Alle zeigen auf dieselbe Codebase, die Middleware löst per Hostname auf. Keine Subdomains, keine Port-Tricks – schmerzfreies lokales Multi-Brand-Setup. + +## 3. Theming-System (Tailwind v4 + CSS Custom Properties) + +### Empfehlung: Tailwind v4 + +Falls die Migration auf v4 noch offen ist: **jetzt machen**. Die `@theme`-Direktive in v4 macht Multi-Brand-Theming dramatisch einfacher als das v3-Config-Konstrukt. Native CSS-Variablen, keine PostCSS-Akrobatik mehr. + +### Token-Architektur + +Drei Ebenen (Beispiel ): + +```css +/* Ebene 1: Globale Design-Tokens (markenneutral) */ +@theme { + --font-serif: 'Source Serif 4', Georgia, serif; + --font-sans: 'Inter', system-ui, sans-serif; + + --spacing-section: 5rem; + --spacing-section-tight: 3rem; + + --radius-card: 2px; /* fast keine Rundungen, editorial */ +} + +/* Ebene 2: Semantische Tokens (markenneutral, aber rollenbasiert) */ +:root { + --color-text-primary: var(--brand-text); + --color-text-muted: var(--brand-text-muted); + --color-surface: var(--brand-surface); + --color-accent: var(--brand-accent); + --color-cta-bg: var(--brand-cta-bg); + --color-cta-fg: var(--brand-cta-fg); + --color-hub-transition: var(--brand-hub-bg); +} + +/* Ebene 3: Brand-spezifische Werte */ +[data-brand="businessportal24"] { + --brand-text: #1a1a1a; + --brand-text-muted: #6b6b6b; + --brand-surface: #fafaf7; /* warmer off-white */ + --brand-accent: #d94e1f; /* gedämpftes Orange */ + --brand-cta-bg: #d94e1f; + --brand-cta-fg: #ffffff; + --brand-hub-bg: #1a2540; /* dunkelblau, Störer */ +} + +[data-brand="presseecho"] { + --brand-text: #f0f0e8; + --brand-text-muted: #a0a098; + --brand-surface: #1f2620; /* dunkelgrün-anthrazit */ + --brand-accent: #5a8a6b; /* gedämpftes Grün */ + --brand-cta-bg: #5a8a6b; + --brand-cta-fg: #ffffff; + --brand-hub-bg: #1a2540; /* Hub-Farbe bleibt konstant! */ +} +``` + +### Brand-Aktivierung im Layout + +```blade +{{-- resources/views/layouts/brand.blade.php --}} + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + {{-- Brand-CSS wird im app.css via @import geladen, oder optional separat: --}} + @if($brand->has_custom_css) + slug}.css") }}"> + @endif + + + {{ $slot }} + + +``` + +**Wichtig:** Der `data-brand`-Attribut auf `` ist der einzige Hebel, der den gesamten Look umschaltet. Alle Tailwind-Utilities, die brand-spezifische Werte nutzen, greifen über CSS-Variablen darauf zu. + +### Pragmatische Tailwind-Nutzung + +Die Komponenten schreiben **nicht** `bg-orange-600` (das wäre brand-spezifisch im Markup festgenagelt). Stattdessen: + +```blade + +``` + +Oder noch sauberer mit eigenen Tailwind-Utility-Klassen, die in `app.css` definiert werden: + +```css +@layer components { + .btn-cta { + @apply bg-[var(--color-cta-bg)] text-[var(--color-cta-fg)] + px-6 py-3 rounded-sm font-medium hover:opacity-90 transition; + } + .btn-hub { + @apply bg-[var(--color-hub-transition)] text-white + px-8 py-6 block; + } +} +``` + +So bleibt das Markup brand-agnostisch und die Stilfragen zentralisiert. + +## 4. Komponenten-Hierarchie und Engine-Wahl + +### Drei Render-Modi, drei Anwendungsbereiche + +|Modus|Wann verwenden|Performance|Beispiele| +|---|---|---|---| +|**Blade Component**|Statisches Markup, keine Interaktion|⚡⚡⚡|TopBar, Footer, PressItem, StatsRow| +|**Volt (Single-File)**|Lokaler State, einfache Reaktivität, Lifecycle einfach|⚡⚡|AdHocTicker, HeroSlider, Search| +|**Klassisches Livewire**|Komplexe Komponenten mit Services, Events, mehrere Methoden|⚡|PressEditor, NewsroomDashboard| + +### Volt: konkrete Empfehlung + +Volt ist für dieses Projekt **die richtige Wahl als Default für reaktive Komponenten** – aber nicht für statische. Die Gründe: + +**Pro Volt:** + +- Single-File-Komponenten: PHP-Logik + Blade-Template + Tailwind-Klassen in einer Datei. Solo-Entwickler-Freundlichkeit ist hoch. +- Funktionale API ist deutlich weniger Boilerplate als klassische Livewire-Klassen. +- Volt-Komponenten lassen sich genau wie Livewire-Komponenten lazy-laden (``), was für Above-the-fold-Performance wichtig ist. + +**Kontra Volt:** + +- Für reine Display-Komponenten ist Volt overkill. Eine `` ohne State soll keine Livewire-Komponente sein – Hydration und Wire-Tracking sind unnötige Kosten. +- Wenn eine Komponente Services injiziert, ein eigenes Test-Setup braucht oder mehr als ~150 Zeilen wächst, ist eine klassische Livewire-Klasse besser strukturierbar. + +**Faustregel:** + +> Renderst du HTML ohne Server-Interaktion? → Blade Component. Brauchst du `wire:model`, `wire:click`, Polling oder reaktiven State? → Volt. Wird die Komponente komplex, hat Services, eigene Tests? → Klassisches Livewire. + +### Konkrete Komponenten-Inventur aus den Screens + +Aufgeschlüsselt nach Engine. Das ist die direkte Übersetzung der Screens in technische Bausteine: + +#### Blade-Komponenten (statisch, hochfrequent wiederverwendet) + +``` + -- Wirtschafts-Ticker, Sprachen, Newsletter/RSS + -- Logo, Suche, CTAs + -- Hauptnavigation (Wirtschaft, Tech, Finanzen...) + -- Footer mit Cross-Brand-Hinweis + + -- Standard-Listen-Eintrag mit Slots für Varianten + -- Große Hero-Variante + -- Kompakte Sidebar-Variante (mit Nummerierung) + -- Quelle · Zeit · Lesezeit (wiederverwendbar) + + -- "§ 01" + Label + H2 (das Editorial-Pattern) + -- Drei-/Vier-Spalten-Statistik + -- CTA-Button mit Varianten (primary, secondary, hub) + -- Branchen-Marker, "Geprüft"-Label + + -- DER dunkelblaue Störer (siehe Briefing) + -- Inline Hub-CTA (Variante des Störers) + + -- "Alle Pressemitteilungen werden geprüft..." +``` + +#### Volt-Komponenten (reaktiv, isolierter State) + +``` + -- Auto-refresh alle 30s, Polling + -- 3 Top-Meldungen, auto-rotate mit Alpine + -- Suche im Header + -- "Alle · Heute · Diese Woche" Tabs + -- Newsroom-Sidebar mit "heute aktiv" Polling + -- Live-Werte mit ± Indikatoren + -- Termine-Karussell mit Wochen-Navigation +``` + +#### Klassisches Livewire (komplex, services) + +``` +PressSubmissionForm -- Mehrstufige Einreichung (auf Hub) +NewsroomManager -- Profil-Verwaltung (auf Hub) +AdminReviewQueue -- Redaktions-Tool (auf Hub) +``` + +Auffällig: die **Brand-Portale brauchen kaum klassisches Livewire**. Das ist konsistent zur Architektur aus Update 3 – Brand-Portale sind primär Lese-Oberflächen, der State liegt im Hub. + +## 5. Brand-Differenzierung in Komponenten + +Drei Mechanismen, in aufsteigender Eingriffstiefe: + +### 5a. Konfiguration über Brand-Config + +Der einfachste Fall: eine Komponente verhält sich anders je nach Brand-Konfiguration. (Beispiel) +siehe: config/domains.php + +```php + +return [ + 'name' => 'businessportal24', + 'tagline' => 'Pressemitteilungen · DACH', + 'press_item_layout' => 'timeline', // vs. 'topic' + 'show_market_ticker' => true, + 'show_branchen_index' => true, + 'hero_variant' => 'top-meldung', // vs. 'topic-cluster' + 'rubriken' => ['Wirtschaft', 'Technologie', /* ... */], +]; + + +return [ + 'name' => 'presseecho', + 'tagline' => 'Branchen-Pressearchiv', + 'press_item_layout' => 'topic', + 'show_market_ticker' => false, + 'show_branchen_index' => false, + 'hero_variant' => 'topic-cluster', + 'rubriken' => [/* andere Reihenfolge, andere Schwerpunkte */], +]; +``` + +Komponenten lesen daraus: + +```blade +@if($brand->config('show_market_ticker')) + +@endif + + +``` + +**Das ist die häufigste Form der Differenzierung** – und sie reicht für ~80 % aller Fälle. + +### 5b. Slots und Defaults in Komponenten + +Wenn eine Komponente strukturell gleich ist, aber Inhalte/Sprache abweichen: + +```blade +{{-- resources/views/components/hub/transition-block.blade.php --}} +@props([ + 'title' => $brand->config('hub_cta.title') ?? 'Pressemitteilung einreichen', + 'description' => $brand->config('hub_cta.description'), + 'buttonText' => $brand->config('hub_cta.button') ?? 'Zum Publisher-Bereich', +]) + + +``` + +Brand-Texte stehen in Config, Komponente bleibt eine. + +### 5c. View-Override pro Brand (Eskalations-Pfad) + +Für die seltenen Fälle, in denen eine Brand wirklich ein anderes Markup braucht: Laravel kann View-Pfade brand-spezifisch erweitern. + +```php +// app/Providers/BrandServiceProvider.php +public function boot(): void +{ + $this->app['view']->prependLocation( + resource_path("views/themes/{$brand->slug}") + ); +} +``` + +Dann sucht Laravel View-Dateien zuerst unter `resources/views/themes/presseecho/components/press/item.blade.php`, dann unter dem Standard-Pfad. **Nur** für die Komponenten, die wirklich anders sein müssen, wird eine Override-Datei angelegt. + +> **Disziplin-Regel:** View-Overrides sind die letzte Eskalationsstufe. Erst versuchen, mit Config + Slots auszukommen. Override-Dateien verdoppeln Wartungsaufwand – jeder Bugfix muss mehrfach gemacht werden. + +## 6. Datei-Struktur (Beispiel, siehe akutelle Struktur und optimiere falls nötig ) + +``` +app/ +├── Brand/ +│ ├── Brand.php # Eloquent Model +│ ├── BrandManager.php # Service, im Container +│ └── BrandResolver.php # Domain → Brand +├── Http/ +│ └── Middleware/ +│ └── ResolveBrand.php +├── Livewire/ +│ ├── Brand/ # Brand-Portal-spezifisch +│ │ ├── AdHocTicker.php +│ │ ├── HeroSlider.php +│ │ └── PressSearch.php +│ └── Hub/ # Hub-spezifisch +│ ├── PressSubmissionForm.php +│ └── NewsroomManager.php +├── View/ +│ └── Components/ +│ ├── Brand/ +│ ├── Press/ +│ ├── Hub/ +│ ├── Ui/ +│ └── Quality/ +└── Providers/ + └── BrandServiceProvider.php + +config/ +└── brands/ + ├── businessportal24.php + ├── presseecho.php + └── hub.php + +resources/ +├── css/ +│ ├── app.css # Tailwind base + semantische Tokens +│ └── themes/ +│ ├── businessportal24.css # Brand-Tokens (optional separat) +│ └── presseecho.css +├── js/ +│ └── app.js +└── views/ + ├── layouts/ + │ ├── brand.blade.php # Brand-Portal-Layout + │ └── hub.blade.php # Hub-Layout + ├── components/ # Standard-Komponenten + │ ├── brand/ + │ ├── press/ + │ ├── hub/ + │ ├── ui/ + │ └── quality/ + ├── livewire/ # Volt-Komponenten + │ ├── ad-hoc-ticker.blade.php + │ ├── hero-slider.blade.php + │ └── press-search.blade.php + ├── pages/ # Konkrete Seiten-Templates + │ ├── home.blade.php + │ └── veroeffentlichen.blade.php + └── themes/ # NUR Brand-Overrides + └── presseecho/ + └── components/ + └── ... # nur was wirklich anders ist +``` + +## 7. Performance-Strategie + +Vier konkrete Hebel, die in dieser Reihenfolge ausgeschöpft werden: + +**1. Aggressive View-Caching für statisches Markup.** Press-Listen, Newsroom-Sidebars, Statistik-Zeilen können mit Tag-basiertem Cache gepuffert werden. Neue Mitteilung → relevante Tags invalidieren. + +```php +Cache::tags(['press_list', "brand.{$brand->slug}"]) + ->remember('home.aktuelle-meldungen', now()->addMinutes(5), fn() => /* ... */); +``` + +**2. Volt-Komponenten lazy laden, wo sinnvoll.** Below-the-fold-Komponenten (Branchen-Index, Termine, Newsroom-Liste) als `lazy`: + +```blade + +``` + +Sie laden erst beim Scroll, blockieren nicht das initiale Render. + +**3. Asset-Pipeline: ein Bundle, alle Brands.** Über die CSS-Variablen-Strategie ist kein Per-Brand-Build nötig. Ein Vite-Build, der für alle Brands gilt. Spart Komplexität und Cache-Invalidierung. + +**4. Brand-Resolution cachen.** Die Domain-zu-Brand-Auflösung ist `rememberForever` (siehe Middleware). Cache-Bust nur beim Brand-Update über Model-Observer. + +## 8. Migration der Bestands-Inhalte + +Quer zu allem oben: die ~100.000 Bestandsmitteilungen sind im neuem System migriert! Drei Punkte, die das Komponenten-Design beeinflussen: + +- **`` muss tolerant gegenüber unvollständigen Daten sein.** Alte Mitteilungen haben evtl. keine Lesezeit-Schätzung, keine Branchen-Zuordnung, keine sauberen Bilder. Komponente rendert auch dann sauber. +- **Permalink-Stabilität.** Die alte URL-Struktur muss erhalten bleiben (Strategie-Dokument: Tombstone-Modell). Das ist ein Routing-Thema, kein Komponenten-Thema – aber die Komponenten dürfen keine URLs hardcoden, sondern nur `route()`-Helpers nutzen. +- **Brand-Zuordnung der Bestände.** Wie in Update 3 festgelegt: am Start ist jede Mitteilung beiden Brands zugewiesen. Komponenten brauchen dafür keine Sonderlogik – sie filtern nach Brand-Kontext, und der Pool ist eben (am Anfang) für beide Brands derselbe. + +## 9. Entwicklungs-Reihenfolge (Empfehlung) + +Konkrete Bauplan-Sequenz, die früh nutzbare Ergebnisse liefert: + +**Sprint 1 – Fundament** + +- Brand-Model, Middleware, Resolver +- Theming-Setup (Tailwind v4, CSS-Variablen, zwei Brand-Themes) +- Layout `brand.blade.php` +- Grundlegende UI-Komponenten (`button`, `badge`, `section-header`) + +**Sprint 2 – Statisches Markup für businessportal24** + +- TopBar, Header, RubrikenNav, Footer +- `` und seine Varianten +- StatsRow, QualityStandardFooter +- Statische Version der Veröffentlichen-Landing (ohne Reaktivität) + +**Sprint 3 – Reaktive Komponenten** + +- AdHocTicker (Volt + Polling) +- HeroSlider (Volt + Alpine) +- PressList mit Filter-Tabs +- Hub-Transition-Block mit Cross-Domain-Auth-Übergabe + +**Sprint 4 – Hub-Anbindung** + +- Sanctum-Setup für Cross-Domain +- Hub-Routing für `?brand=businessportal24`-Parameter +- Einreichungs-Flow im Hub (klassisches Livewire) + +**Sprint 5 – Zweite Brand aufschalten** + +- `presseecho.test` lokal +- Brand-Config für presseecho +- Erste Override-Komponente: `topic-cluster`-Hero +- Testen: was funktioniert ohne Override, was braucht eines? + +Ab Sprint 5 wird die eigentliche Stresstest-Frage beantwortet: **Hält die Architektur, wenn die zweite Brand wirklich anders aussehen soll?** Wenn an Sprint 5 viele Overrides nötig werden, ist die Config-Schicht zu dünn – dann iterieren. + +## 10. technische Punkte + +- **Tailwind v4:** wenn das Projekt noch nicht migriert ist, sollte das _vor_ Sprint 1 entschieden werden. v4 macht das CSS-Variablen-Setup deutlich eleganter. +- **Sanctum-Cookie-Domain für Cross-Domain-Auth:** Detail aus Update 3, muss vor Sprint 4 final geklärt sein. Same-Site-Strategie, SPA-Mode oder klassischer Token-Flow? +- **CDN/Asset-Hosting:** Brand-Bilder, Press-Item-Fotos – kommen vom Hub +- **Translations:** DACH-Sprachschalter auf der Startseite –(de (ohne parameter) / de-at / de-ch / en) ist eine reine Inhalts-Filterung. Bei Mehrsprachigkeit de/en: i18n-Setup +- **Polling-Frequenzen:** AdHocTicker, Newsroom-Liste – wie oft refreshen, ohne dass die Server-Last bei wachsendem Traffic problematisch wird? Anfangswerte: Ticker 30s, Newsroom 60s, Branchen-Index 5 min. + +--- + +_Dieses Konzept ist die technische Brücke zwischen Architektur (Update 3), Positionierung (Update 4) und Implementation. Es legt fest, wie Komponenten strukturiert werden, damit die Brand-Differenzierung skaliert – ohne in eine Codebase-Duplikation zu kippen. Anpassungen sollten dokumentiert und mit den Update-Dokumenten abgeglichen werden._ \ No newline at end of file diff --git a/docs/konzept/Konzept Presseportal – Marktposition & Hebel.md b/docs/konzept/Konzept Presseportal – Marktposition & Hebel.md new file mode 100644 index 0000000..e070a38 --- /dev/null +++ b/docs/konzept/Konzept Presseportal – Marktposition & Hebel.md @@ -0,0 +1,290 @@ + +**Stand:** Mai 2026 **Portale:** presseecho.de, businessportal24.de **Zweck:** Strategisches Leitkonzept zur Differenzierung gegenüber Wettbewerbern und zur schrittweisen Reaktivierung der beiden übernommenen Portale. + +--- + +## 1. Ausgangslage + +Zwei übernommene Pressekonto, über 10 Jahre alt, mit zusammen rund 100.000 archivierten Pressemitteilungen. Frontend nicht responsive, Backend technisch veraltet. Aktive Bestandskunden vorhanden, größerer inaktiver Stamm. Aktuell ca. 50 % des Traffics über connektar.de als Distribution-Partner. + +Relaunch in Umsetzung: Laravel-Backend mit 1:1-Datenmigration, neues Tailwind-basiertes Frontend, schrittweise Markendifferenzierung der beiden Portale. + +**Entwicklung:** Solo, neben Hauptberuf, mit KI-gestützter Entwicklung. **Laufende Kosten:** Minimal (Hetzner-Server ~10 €/Monat). **Existenzdruck:** Keiner. Projekt ist optionales Nebeneinkommen. + +--- + +## 2. Marktposition: bewusste Nische statt Konkurrenz + +### Ehrliche Einschätzung des Marktes + +Klassische Pressekonto verlieren redaktionelle Relevanz, behalten aber SEO-Funktion. Käufer buchen Pressemitteilungen heute nicht mehr für Journalistenanrufe, sondern für: + +1. Backlinks und SEO-Substanz +2. Google-Sichtbarkeit zu konkreten Suchbegriffen +3. Digitale Sichtbarkeit (Investoren, Partner, Bewerber finden aktuelle PMs) +4. Inhalte für eigene Newsroom-Seiten + +Wettbewerber wie openPR und Pressebox kommunizieren weiterhin das alte Versprechen („Reichweite zu Journalisten") und sind in Lock-in-Modellen, Vertriebsstrukturen und Distributions-Verträgen gefangen. + +### Eigene Positionierung + +**„Pressemitteilungen für digitale Sichtbarkeit – ehrlich, fair, ohne Lock-in."** + +Keine Massenabdeckung. Keine aggressive Skalierung. Eine ruhige, durchdachte Alternative, die genau die Käufer anspricht, die mit dem klassischen Modell unzufrieden sind. + +### Strukturelle Vorteile gegenüber Wettbewerbern + +- Niedrige Fixkosten → keine Notwendigkeit für aggressive Tarifmodelle +- Solo-Entwicklung → schnelle, ehrliche Produktentscheidungen ohne Vertriebs-Druck +- 100.000 PMs Archiv + gewachsene Domain-Autorität → nicht reproduzierbares Asset +- Kein Investorendruck → kann bewusst klein, nachhaltig und langfristig wachsen + +--- + +## 3. Realistische Erfolgsdefinition + +**Erfolg heißt nicht:** Verdrängung der Marktführer, hohe Wachstumsraten, große Userzahlen. + +**Erfolg heißt:** + +- Stabiler Betrieb mit minimalen Fixkosten +- Wachsender Stamm zufriedener Bestandskunden +- Planbares monatliches Einkommen (mittelfristig 3.000–8.000 €) +- Mit dem Alter: lukratives Nebeneinkommen +- Verkaufbar, falls gewünscht + +**Zeithorizont:** 3–5 Jahre für stabile Einkommensbasis. + +--- + +## 4. Was wir bewusst NICHT machen + +Diese Liste ist wichtiger als jede Feature-Roadmap. Sie schützt vor Entscheidungen in schwachen Momenten. + +- **Keine Lock-in-Verträge** mit 12-Monats-Mindestlaufzeit +- **Keine kostenlosen PMs** (zieht falsche Zielgruppe an) +- **Keine Fake-Urgency** im Marketing („Nur noch 2 Plätze frei") +- **Keine versteckten Gebühren** bei Standard-Korrekturen +- **Keine Vermarktung als „Reichweite zu X Journalisten"** – ehrliches Versprechen statt PR-Floskeln +- **Keine aggressiven Pop-ups** beim Verlassen +- **Keine Newsletter-Pflichtanmeldung** im Bestellprozess versteckt +- **Keine „Auf Anfrage"-Preisverschleierung** – klare Preise auf der Verkaufsseite + +--- + +## 5. Differenzierungs-Hebel + +### Hebel 1: Friction-freier Einstieg (sofort wirksam) + +**Botschaft auf der Verkaufsseite:** + +> „Keine Mindestlaufzeit. Monatlich kündbar. Faire Preise." + +Direkter Konter gegen openPR (12 Monate Mindestlaufzeit, jährliche Vorauszahlung) und Pressebox (ähnliches Modell). Stärkster Hebel ohne Entwicklungsaufwand – reine Marketingbotschaft. + +### Hebel 2: SEO-Substanz und Vertrauen herausstellen + +Auf der Verkaufsseite kommunizieren: + +- „Online seit 2008" +- „Über 100.000 Pressemitteilungen archiviert" +- Domain-Autorität als Trust-Signal +- Echte Aufrufzahlen pro PM (statt nur „Reichweite versprochen") + +Wirkt bei B2B-Käufern stark, weil Vertrauen signalisiert wird – etwas, das KI-Tool-Plattformen mit 2 Jahren Marktpräsenz nicht liefern können. + +### Hebel 3: Faires Korrektur- und Änderungsmodell + +**Was Wettbewerber machen:** openPR berechnet 10 € pro Änderung, auch bei Tippfehlern. + +**Was wir machen:** + +- Tippfehler / Kontaktdaten-Korrekturen: kostenfrei (Self-Service) +- Inhaltliche Korrektur: mit transparentem Korrekturhinweis +- Updates: als Anhang an Original +- Tombstone bei Löschung statt Hard-Delete (SEO-Erhalt) +- Anonymisierung bei DSGVO-Anfragen ohne Diskussion + +Adressiert echten Schmerz von Bestandskunden anderer Portale. + +### Hebel 4: Smarte Add-ons mit Konversionslogik + +Credit-System ermöglicht margenstarke Zusatzumsätze nach der Erstbuchung: + +- Highlight-Buchung im Anschluss („7 Tage prominent platziert") +- KI-Bildgenerierung +- Cross-Post zwischen presseecho.de und businessportal24.de +- KI-Quality-Check / Stilverbesserung +- Newsletter-Erwähnung (wenn Newsletter aufgebaut) + +### Hebel 5: Distribution über die richtigen Kanäle + +**Falsch wäre:** Eigene Social-Media-Accounts der Portale aufbauen → kostet Zeit, baut keine Reichweite auf, kann Reputation schaden. + +**Richtig ist:** + +- **Share-Funktionen für Kunden:** Nach Veröffentlichung vorgefertigte Share-Texte für LinkedIn, X, Facebook, WhatsApp +- **LinkedIn priorisieren:** B2B-Distribution läuft 2026 dort, nicht auf X +- **Open-Graph- und Twitter-Card-Tags** sauber konfigurieren, damit geteilte Links gut aussehen +- **Optional Phase 3:** kuratierter „Best of"-Channel mit harter KI-Score-Schwelle (>85), nur wenn Zeit dafür da ist + +### Hebel 6: Brand-Schutz absichern + +- DPMA-Marken für presseecho.de und businessportal24.de prüfen, ggf. anmelden (~290 € pro Wortmarke) +- Google Ads Brand-Schutz-Kampagnen für eigene Portalnamen (~20–50 €/Monat) +- Verhindert, dass Wettbewerber bei Markensuchen oben stehen + +--- + +## 6. Preismodell (bewusst gegen den Markt) + +|Tier|Preis|Pressemitteilungen|Inklusive| +|---|---|---|---| +|**Einzel**|29 € / Stück|1|Free-Stock, KI-Quali-Check| +|**Starter**|19 €/Mo. oder 190 €/Jahr|3/Monat, weitere à 15 €|Free-Stock, KI-Quali| +|**Business**|59 €/Mo. oder 590 €/Jahr|10/Monat|+ 1 Highlight, 5 Premium-Stock, 10 KI-Bilder| +|**Pro**|119 €/Mo. oder 1.190 €/Jahr|unbegrenzt (Fair Use)|+ 3 Highlights, größere Kontingente, Priority| +|**Agency**|249 €/Mo. oder 2.490 €/Jahr|unbegrenzt für bis zu 5 Marken|alles aus Pro × Marken, je weitere Marke 39 €/Mo.| + +**Eckpunkte:** + +- Alle Tarife monatlich kündbar +- Jahrespreise mit ~15 % Rabatt +- Credit-System für flexible Add-ons +- Mengenrabatte ab 3 PMs in 30 Tagen (z. B. 20 % auf nächste PM) +- Reaktivierungs-Gutschein für Bestandskunden zum Relaunch (z. B. 50 % auf nächste PM) + +**Launch-Aktion:** „Erste PM für 9 € statt 29 €" – einmalig pro Account, neue Accounts auf gleiche Firma/E-Mail werden erkannt. + +--- + +## 7. Akquise-Strategie + +### Phase 1: Bestandskundenreaktivierung (sofort) + +- Persönliche Kontaktaufnahme zu aktiven Bestandskunden (20 Telefonate vor Relaunch wertvoller als jedes Marketingkonzept) +- Reaktivierungs-Mailing an inaktiven Stamm mit konkretem Gutschein +- Realistischer Erwartungswert: 5–15 % Reaktivierung bei persönlicher Ansprache, 1–3 % bei Massenmail + +### Phase 2: Organische Sichtbarkeit (mittelfristig) + +- Saubere Migration ohne SEO-Verlust (saubere Redirects, Tombstone-Modell, schnelle Ladezeiten) +- Bestandsarchiv als Long-Tail-Magnet pflegen +- Content-Marketing über das Portal selbst (redaktionelle Übersichten, Branchen-Specials) + +### Phase 3: Empfehlungsmarketing (langfristig) + +- Empfehlungs-System: Bestandskunden bekommen Rabatt für gebrachte Neukunden +- Funktioniert in B2B-Nischen besser als bezahltes Marketing, weil Empfehlungen das Vertrauensthema lösen + +### Bezahlte Werbung – realistisch eingesetzt + +- **Brand-Schutz** (Pflicht): Eigene Brandnames bei Google Ads buchen (~20–50 €/Monat) +- **Long-Tail-Branchenkeywords** (Test): „Pressemitteilung [Branche] veröffentlichen" mit kleinem Budget (200–300 €/Monat) testen +- **Nicht** auf Hauptkeywords wie „Pressemitteilung veröffentlichen" bieten – CPCs zu hoch, ROI bei Solo-Setup nicht gegeben + +--- + +## 8. Connektar.de als Distribution-Partner + +50 % des aktuellen Traffics läuft über connektar.de. Risiko und Asset zugleich. + +**Strategie:** + +- Weiterlaufen lassen, aber Abhängigkeit transparent machen +- Eigenständige Akquise parallel aufbauen +- Datenmodell vorbereiten: `source: distribution_partner`, separate Statistiken +- Qualitäts-SLA tracken (KI-Ablehnungsrate) +- Bei Bedarf in Zukunft: eigenes Kündigungsrecht bei Qualitätsproblemen verhandeln + +--- + +## 9. Technisches Setup + +- **Backend:** Laravel +- **Frontend:** Tailwind, responsive +- **Hosting:** Hetzner (~10 €/Monat) +- **Deployment:** Ploi +- **Tracking:** Umami self-hosted auf separater VM (DSGVO-konform, kein Cookie-Banner, kein Adblocker-Problem) +- **Zusätzlich:** Serverseitiger Aufruf-Counter in Laravel für verlässliche PM-Statistiken + +--- + +## 10. Phasen-Roadmap + +### Phase 1 – MVP / Relaunch + +- Backend-Migration auf Laravel +- Responsive Frontend mit Tailwind +- 1:1-Datenmigration der Bestands-PMs +- Magic-Link-Login für Self-Service-Änderungen +- Korrektur- und Tombstone-Modell +- KI-Vorprüfung neuer PMs +- Credit-System mit Tarifstruktur +- Share-Buttons mit Open-Graph-Tags +- Marketingbotschaft „Keine Mindestlaufzeit" prominent +- Brand-Schutz-Kampagnen aktivieren +- Bestandskundenreaktivierung + +### Phase 2 – Konsolidierung (3–6 Monate nach Launch) + +- Cross-Post zwischen den beiden Portalen +- Highlight-Buchungen mit eleganter UX +- KI-Score-Anzeige für Kunden (Transparenz) +- Statistik-Dashboard für Kunden (eigene Aufrufzahlen) +- Empfehlungsmarketing-System + +### Phase 3 – Differenzierung (6–12 Monate) + +- Schärfere Markentrennung presseecho.de vs. businessportal24.de +- LinkedIn-Auto-Post für Pro/Agency-Tier +- Optional: kuratierter Best-of-Social-Channel mit hoher Schwelle +- Newsletter-Aufbau, Newsletter-Erwähnung als Add-on + +### Phase 4 – Wachstum & Optimierung (laufend) + +- Quartals-Rhythmus für kontinuierliche Verbesserungen +- Bestandskunden regelmäßig befragen +- Tarifmodell datenbasiert nachschärfen +- Bei stabiler Basis: ggf. weitere Add-ons / Premium-Features + +--- + +## 11. Erfolgskontrollen und Stopps + +Klare Stopps definieren, um nicht im Zombie-Modus zu landen: + +**Nach 9 Monaten Live-Betrieb prüfen:** + +- Wachsender Bestandskundenstamm? +- Planbarer monatlicher Umsatz erkennbar? +- Connektar-Anteil sinkend (Eigenakquise zieht)? +- Reaktivierungsrate alter Kunden im Erwartungsbereich? + +**Falls keine positive Entwicklung sichtbar:** + +- Konsolidierung auf eine Marke prüfen +- Verkaufsoption prüfen +- Reines Archiv-Modell mit minimaler Pflege erwägen +- Kein „weiter so" aus Trägheit + +**Anti-Zombie-Regel:** Mindestens einmal pro Quartal ein halber Tag „Was muss verbessert werden". Ohne Ausnahme. + +--- + +## 12. Strategische Leitlinien zusammengefasst + +1. **Nische besetzen, nicht konkurrieren.** +2. **Modernes Verständnis von Pressemitteilungen** kommunizieren – digitale Sichtbarkeit, SEO, ehrliche Versprechen. +3. **Friction-frei sein**, wo Wettbewerber Friction aufbauen (Mindestlaufzeit, Korrekturgebühren, intransparente Preise). +4. **100.000 PMs als Asset** kommunizieren – nicht reproduzierbar. +5. **Empfehlungsmarketing in B2B-Nische** ist langfristig stärker als bezahltes Marketing. +6. **Geduld als Wettbewerbsvorteil** – kein Investorendruck heißt: organisch wachsen können. +7. **Konsistenz schlägt Spektakel** – kleine kontinuierliche Verbesserungen, alle 6–12 Monate ein nennenswertes Feature. +8. **Erfolg = stabiles Nebeneinkommen**, nicht Marktführerschaft. +9. **Bewusste Selbstverpflichtung** auf das, was man nicht macht. +10. **Bei jeder Feature-Entscheidung fragen:** Passt das zu einem ruhigen, fairen, durchdachten Portal – oder zu einem aggressiven Wachstums-Tool? + +--- + +_Diese Strategie ist ein lebendes Dokument. Sie sollte mindestens einmal jährlich überprüft und an die tatsächliche Marktentwicklung angepasst werden._ \ No newline at end of file diff --git a/docs/konzept/Konzept-Update 1 – Überarbeitete Abschnitte.md b/docs/konzept/Konzept-Update 1 – Überarbeitete Abschnitte.md new file mode 100644 index 0000000..8ca2a32 --- /dev/null +++ b/docs/konzept/Konzept-Update 1 – Überarbeitete Abschnitte.md @@ -0,0 +1,450 @@ + +Stand: Mai 2026 Zweck: Ersatz bzw. Ergänzung der Abschnitte 8, 9, 10 sowie neue Abschnitte zur Score-Architektur, Boost-Eligibilität und zum Tool-Loop. Datenmodell-Ergänzungen am Ende. + +> **IST-Stand 11.06.2026**: +> +> - **§8–10 (Tarife, Credits, Preisliste) sind ueberschrieben** durch das +> [`Decision-Update Preisstruktur & Veröffentlichungs-Flow`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) +> (11.06.2026): neue Kontingente (Pro 25 statt 60, Agency 60 statt 150), +> Jahrespreis als „2 Monate gratis", Bonus-Credits aus der Tarif-Tabelle +> entfernt, Launch-Credits auf Extra-PM/Boost/PDF-Nachweis reduziert. +> Die Abschnitte unten bleiben als urspruengliche Zielvorstellung lesbar. +> - **§15.1 (Klassifikations-Score) und §15.2 (Content-Score) sind umgesetzt** +> (11.06.2026, siehe `docs/user-admin/Entwicklungsplan KI-Pruefung und +> Veroeffentlichung.md`). §15.3 (Trust-Score), §16 (Boost) und §17 +> (Tool-Loop) sind weiterhin offen. +> - Zahlung/Stripe, Tarif-Datenmodell, Credit-Pakete und Auto-Refill sind +> **nicht** umgesetzt. Im Code existiert nur der Phase-8-**Quota-Stub** +> (`users.press_release_quota`, zaehlt beim Einreichen — laut +> Decision-Update kuenftig bei Veroeffentlichung). + +--- + +## 8. Preismodell – Tarife (überarbeitet) + +### Grundlogik + +Alle Tarife enthalten ein Kontingent an Pressemitteilungen sowie monatlich ausgeschüttete Bonus-Credits für Tools und Add-ons. Bonus-Credits aus Abos verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig. So bleibt das Abo aktivierungsstark, ohne dass der Nutzer eigenes Geld verliert. + +### Tier-Struktur + +| Tier | Preis | PMs | Bonus-Credits/Mo. | Effektiver PM-Preis | Besonderheiten | +| ------------ | ------------------------ | ----------------------- | ------------------------ | ------------------- | --------------------------------------------------------------- | +| **Einzel** | 19 € / Stück | 1 | 4 (verfallend nach 30 T) | 19,00 € | Pay-as-you-go | +| **Starter** | 29 €/Mo. (290 €/Jahr) | 3 | 12 | 9,67 € | Free-Stock, KI-Quality-Check | +| **Business** | 49 €/Mo. (490 €/Jahr) | 10 | 30 | 4,90 € | Erweiterte Statistiken, optionaler Newsroom | +| **Pro** | 99 €/Mo. (990 €/Jahr) | unbegrenzt (Fair Use) | 60 | < 2 € | Eigener Newsroom, Priority, volles Statistik-Dashboard | +| **Agency** | 199 €/Mo. (1.990 €/Jahr) | unbegrenzt für 5 Marken | 120 | < 1 € | Multi-Redakteur-Workflow, API-Zugang, je weitere Marke 29 €/Mo. | + +Jahrespreise mit ca. 17 % Rabatt eingebaut. Fair Use im Pro-Tarif: Soft-Cap 50 PMs/Monat. + +### Mehrwerte im Vergleich + +|Feature|Einzel|Starter|Business|Pro|Agency| +|---|---|---|---|---|---| +|Pressemitteilungen|1|3/Mo.|10/Mo.|unbegr.|unbegr. (5 Marken)| +|Bonus-Credits|4 einmalig|12/Mo.|30/Mo.|60/Mo.|120/Mo.| +|Free-Stock-Bilder|✓|✓|✓|✓|✓| +|KI-Quality-Check|✓|✓|✓|✓|✓| +|Erweiterte Statistiken|–|–|✓|✓|✓| +|Eigener Newsroom|–|–|optional|inkl.|inkl.| +|Priority-Support|–|–|–|✓|✓| +|Multi-Redakteur-Workflow|–|–|–|–|✓| +|API-Zugang|–|–|–|–|✓| + +### Kommunikation + +Die inkludierten Bonus-Credits sind Teil des Pakets, nicht zusätzliche Kosten. Reicht das Kontingent nicht (z. B. weil mehrere PMs mit aufwändigem Tooling veröffentlicht werden), kauft der Nutzer Credits nach – diese bleiben 24 Monate erhalten und schaffen langfristige Bindung an die Plattform. + +### Bestandskunden + +Aktive Jahresabos behalten Preis bis zum nächsten Verlängerungstermin. Loyalty-Bonus 10–20 % im ersten Verlängerungsjahr. Downgrade-Pfad anbieten. + +### Einstiegsstrategie + +In der Anfangsphase (erste 6–12 Monate nach Relaunch) bewusst günstiger einsteigen, um User-Base aufzubauen. Preise sind kalkuliert mit Spielraum für spätere Anpassung. Wichtig: Bestandskunden behalten ihre Konditionen. + +--- + +## 9. Credit-System (überarbeitet) + +### Grundregel + +**1 Credit = 1 €** als Listenpreis-Anker. Alle Service-Preise werden in ganzen Credits ausgewiesen. Wer größere Pakete kauft, zahlt effektiv weniger pro Credit (Volumenrabatt), aber der Listenpreis bleibt stabil. So entfällt jede Kopfrechen-Übung im UI. + +### Credit-Pakete + +|Paket|Credits|Preis|Effektiv pro Credit|Ersparnis| +|---|---|---|---|---| +|Test|10|10 €|1,00 €|–| +|Standard|50|45 €|0,90 €|10 %| +|Plus|150|120 €|0,80 €|20 %| +|Pro|500|375 €|0,75 €|25 %| +|Business|1.500|1.050 €|0,70 €|30 %| + +Ganzzahlige Beträge, keine Bruchteile im UI. Intern kann auf Cent-Ebene abgerechnet werden, aber nach außen sieht der Nutzer nur ganze Credits. + +### Auto-Refill + +Standardmäßig nach erstem Kauf aktiviert (mit Opt-Out): + +- Trigger: bei < 10 Credits Restguthaben +- Aufladung: zuletzt gekauftes Paket (Default Standard, 50 Credits) +- Eindeutige Bestätigungs-Mail nach jeder automatischen Aufladung + +### Gültigkeit + +- Gekaufte Credits: 24 Monate ab Kauf +- Bonus-Credits aus Abos: monatlich verfallend +- Willkommens-Bonus (5 Credits einmalig bei Account-Anlage): 90 Tage + +### Mini-Checkout (kontextuell) + +1. User klickt z. B. „KI-Bild generieren" +2. Modal: _„Kostet 4 Credits. Du hast 2 Credits."_ +3. Optionen: + - „Schnell aufladen: Standard-Paket (50 Credits, 45 €)" – 1-Klick mit Saved Payment Method + - „Anderes Paket wählen" + - „Abbrechen" +4. Nach Aufladung wird Aktion automatisch ausgeführt + +### Erstkauf + +Stripe Checkout mit `setup_future_usage` für Saved Payment Method. Danach 1-Klick-Aufladung. + +### Dashboard + +- Credit-Stand oben rechts immer sichtbar +- Trennung sichtbar: Bonus-Credits (verfallend) vs. gekaufte Credits (24 Monate) +- Verlauf einsehbar (was wofür verbraucht) +- Rechnungs-PDFs für jede Aufladung + +### Buchhaltung & Recht + +- Credits = Vorauszahlung, bilanziell als Verbindlichkeit +- MwSt-Behandlung mit Steuerberater abstimmen (Kauf vs. Verbrauch) +- Verfall in AGB sauber dokumentieren +- Keine Auszahlung in Geld (sonst PSD2-Lizenzthema) +- EU-Auslandskunden: Reverse-Charge bei B2B mit USt-ID + +--- + +## 10. Preisliste in Credits (überarbeitet) + +Alle Preise in ganzen Credits (1 Credit = 1 €). Anker-Werte für die Startphase, iterativ anpassbar. + +### Veröffentlichung + +|Service|Credits| +|---|---| +|Standard-PM (Pay-as-you-go)|19| +|PM-Korrektur (Pfad C)|8| +|PM-Update (Pfad D)|4 _(im ersten Jahr ggf. kostenlos)_| +|Depublizierung (Pfad G)|19–25| + +### Bilder + +|Service|Credits| +|---|---| +|Free-Stock (Unsplash, Pexels)|0| +|Premium-Stock (Adobe, Shutterstock)|8| +|KI-Bild generieren|4| +|KI-Bild Re-Generation|2| + +### KI-Textservices + +|Service|Credits| +|---|---| +|Quality-Check (Stil/Pressestil)|3| +|Lektorat|8| +|Pressetext-Optimierung (Headlines, SEO)|15| +|Headline-Booster (nur Headlines)|5| +|PM aus Stichworten generieren|25| +|Übersetzung (DE↔EN)|12| + +### Platzierungen + +|Service|Credits| +|---|---| +|Highlight Kategorie (3 Tage)|15| +|Highlight Kategorie (7 Tage)|30| +|Startseite-Highlight (24 h)|39| +|Startseite-Highlight (3 Tage)|89| +|Top-Slot Startseite (24 h)|119| +|Newsletter-Erwähnung|59| +|Social-Share (offizieller Kanal)|25| + +Voraussetzung für alle Platzierungen: Mindest-Content-Score erreicht (siehe Abschnitt „Boost-Eligibilität"). + +### Distribution + +|Service|Credits| +|---|---| +|PDF-Export mit Branding|2| +|Social-Snippet-Generierung|3| +|Verteiler-Versand (klein, branchenspezifisch)|39| +|Verteiler-Versand (mittel)|99| +|Verteiler-Versand (groß, branchenübergreifend)|199| + +### Account / Profil + +|Service|Credits| +|---|---| +|Verifiziertes Firmenprofil (einmalig)|79| +|Custom Subdomain (pro Jahr)|49| +|Erweiterte Statistiken (pro Monat)|15| + +### Goodies (kostenlos, fördern Aktivität) + +- PM-Updates kostenfrei im ersten Jahr (besseres Archiv) +- 3 Free-Stock-Bilder pro PM +- Erster KI-Quality-Check pro PM kostenfrei +- 5 Credits Willkommens-Bonus bei Account-Anlage (90 Tage gültig) +- Headline-Vorschlag (1 Variante) kostenfrei pro PM + +--- + +## NEU – Abschnitt 15: Score-Architektur + +Die Plattform arbeitet mit drei voneinander unabhängigen Scores. Sie haben unterschiedliche Funktionen, werden unterschiedlich berechnet und an unterschiedlichen Stellen wirksam. Die Trennung ist zentral, weil sie unterschiedliche Datenmodelle und Update-Logiken betrifft. + +### 15.1 Klassifikations-Score (Eintritts-Filter) + +**Funktion:** Entscheidet, ob eine Pressemitteilung überhaupt veröffentlicht wird. + +**Bereich:** Grün / Gelb / Rot (kategorial) + +**Faktoren:** + +- Werbung statt Pressemitteilung +- Beleidigend / diskriminierend +- Rechtlich heikel +- Spam-Muster +- Unseriöse Versprechen + +**Auswirkung:** + +- Grün: Direkte Veröffentlichung (optional 5–10 Min Verzögerung) +- Gelb: Manuelle Review-Queue +- Rot: Zurück an User mit Begründung + +**Aktualisierung:** Einmalig bei Einreichung. Bei Änderung der PM (Pfad C/D) wird neu klassifiziert. + +**Speicherung:** `press_releases.classification` plus vollständiges Audit-Log in `ki_audits`. + +### 15.2 Content-Score (Qualitäts-Indikator) + +**Funktion:** Misst die handwerkliche Qualität einer Pressemitteilung. Bestimmt organische Sichtbarkeit und Boost-Berechtigung. + +**Bereich:** 0–100 Punkte + +**Faktoren (Vorschlag, iterativ verfeinerbar):** + +|Kategorie|Gewichtung|Was zählt| +|---|---|---| +|Pressestil|20 %|Tonalität (informativ vs. werblich), passive vs. aktive Konstruktion, Zitate vorhanden| +|Struktur|15 %|Lead-Absatz vorhanden, sinnvolle Absatzstruktur, Pyramidaler Aufbau| +|Lesbarkeit|10 %|Flesch-Index für Deutsch, Satzlängen, Fachsprache angemessen| +|Vollständigkeit|15 %|Pressekontakt, Unternehmensinfo, Datum, Branche, Region| +|Bildmaterial|10 %|Mindestens 1 Bild, Auflösung, Alt-Text, Bildunterschrift| +|Quellen / Belege|10 %|Verlinkungen, Studien-Referenzen, Datenquellen| +|Headline-Stärke|10 %|Länge, Keyword-Relevanz, Klarheit| +|Originalität|10 %|Kein Boilerplate, kein Duplicate-Content, individueller Ton| + +**Auswirkung:** + +- **Organische Sichtbarkeit:** Listing-Position, Top-Story-Kandidat, Newsletter-Aufnahme, Trending in Branche +- **Boost-Berechtigung:** Schwellenwerte für kostenpflichtige Slots (siehe Abschnitt 16) +- **User-Feedback:** Sichtbar im Editor-Dashboard mit konkreten Verbesserungsvorschlägen + +**Aktualisierung:** Bei Einreichung berechnet, bei jeder Änderung der PM neu berechnet. History pro PM in `content_scores`. + +**Speicherung:** `press_releases.content_score` (aktueller Wert), `content_scores` (History mit Faktor-Breakdown). + +### 15.3 Trust-Score (Reputations-Indikator) + +**Funktion:** Bewertet die Zuverlässigkeit eines Publishers über Zeit. Reduziert Moderationslast und kann öffentliche Anerkennung bringen. + +**Bereich:** 0–100 oder Stufen (Bronze / Silber / Gold / Verifiziert) + +**Faktoren:** + +- Anzahl problemfrei veröffentlichter PMs +- Durchschnittlicher Content-Score über alle PMs +- Beschwerderate (Reports, Korrekturen, Depublizierungen) +- Account-Alter +- Verifikations-Status (verifiziertes Firmenprofil) + +**Auswirkung:** + +- **Moderation:** Lockerung der KI-Freigabe-Schwelle (mehr „Grün" automatisch) +- **Sichtbarkeit (optional):** öffentliches Verifizierungs-Badge auf Newsroom und PM-Seiten +- **Bevorzugung in Branchen-Übersichten** bei gleichem Content-Score +- **Bei Trust-Verlust:** Rückfall in strengere Moderation (auch nach Beschwerden, häufigen Korrekturen, Depublizierungen) + +**Aktualisierung:** Rollierend, z. B. nächtlicher Cron-Job über die letzten 90 Tage Aktivität. + +**Speicherung:** `accounts.trust_score`, `accounts.trust_tier` (Bronze/Silber/Gold/Verifiziert), History in `trust_score_log`. + +### Offene Detail-Entscheidungen + +- Trust auf User- oder auf Firmen-Ebene? (Empfehlung: Firmen-Ebene, weil Mitarbeiter wechseln) +- Trust-Verlust: ab welchen Schwellen? +- Verifizierungs-Badge: nur über kostenpflichtigen Verifizierungs-Prozess oder auch durch Trust-Score erreichbar? + +--- + +## NEU – Abschnitt 16: Boost-Eligibilität + +Die Verbindung zwischen Score-System und kostenpflichtigen Sichtbarkeits-Slots. Grundprinzip: **Schlechter Content kann nicht in den Top-Slot gekauft werden.** Das schützt die redaktionelle Glaubwürdigkeit der Plattform und schafft den Anreiz, in Qualität zu investieren. + +### Schwellenwerte je Slot-Typ + +|Slot|Klassifikation|Min. Content-Score| +|---|---|---| +|Highlight Kategorie|Grün|50| +|Startseite-Highlight (24h / 3 T)|Grün|65| +|Top-Slot Startseite|Grün|75| +|Newsletter-Erwähnung|Grün|70| +|Social-Share (offizieller Kanal)|Grün|70| +|Verteiler-Versand (extern)|Grün|80| + +PMs mit Klassifikation Gelb können nicht boostbar werden, auch nicht nach manueller Freigabe – sie bleiben in regulärer Sichtbarkeit. PMs mit Klassifikation Rot werden nicht veröffentlicht und sind damit irrelevant. + +### UI-Logik + +Wenn ein User einen Boost-Slot bucht, dessen Schwelle seine PM nicht erreicht, sieht er statt des Buchungsformulars: + +> _„Diese Pressemitteilung erreicht aktuell einen Content-Score von 60/100. Für den Top-Slot Startseite empfehlen wir mindestens 75 Punkte. So kannst du deinen Score verbessern:"_ +> +> _[Pressetext-Optimierung – 15 Credits → +15–20 Punkte]_ _[Headline-Booster – 5 Credits → +3–7 Punkte]_ _[Bild hinzufügen – 4 Credits → +5–10 Punkte]_ + +Nach Tool-Anwendung wird der Score neu berechnet, der Slot kann dann gebucht werden. + +### Effekt + +Drei gewollte Konsequenzen: + +1. **Plattform-Qualität bleibt hoch:** Premium-Slots zeigen nur qualitativ hochwertige Inhalte. +2. **Tools werden indirekt verkauft:** Wer den Slot will, muss in Qualität investieren – entweder selbst oder über kostenpflichtige Tools. +3. **Glaubwürdigkeit für Leser bleibt erhalten:** Leser und Journalisten erkennen schnell, dass sichtbar platzierte Inhalte tatsächlich relevant sind. + +### Sonderfall: Editorial-Pick + +Unabhängig vom Boost-System kann die Redaktion (intern) PMs als „Empfehlung der Redaktion" hervorheben. Das ist ein redaktionelles Instrument, kein kommerzielles, und nicht buchbar. Wirkt als Vertrauensanker auf der Startseite. + +--- + +## NEU – Abschnitt 17: Tool-zu-Algorithmus-Loop + +Der strategische Kern der Monetarisierungslogik. Der Loop verbindet drei Plattform-Ziele in einem geschlossenen System: + +### Die drei Ziele + +1. **Plattform-Qualität:** Hohe durchschnittliche Inhaltsqualität, damit Leser, Journalisten und Mediaplaner die Plattform als seriös wahrnehmen. +2. **Monetarisierung:** Umsatz aus Tools, Tarifen und Boost-Slots. +3. **Anreiz für Publisher:** Sichtbar gute Platzierungen für gute Inhalte als motivierender Faktor. + +### Der Loop + +``` +Publisher schreibt PM + ↓ +Content-Score wird berechnet (z. B. 55/100) + ↓ +Publisher will Top-Slot buchen (Schwelle 75) + ↓ +System empfiehlt: Pressetext-Optimierung (15 Credits) + ↓ +Tool wird angewendet, Score steigt auf 78 + ↓ +Top-Slot wird gebucht (119 Credits) + ↓ +PM erscheint prominent auf Startseite + ↓ +Hohe Reichweite, gute Statistiken + ↓ +Publisher sieht Wert, kommt wieder + ↓ +Plattform-Durchschnittsqualität steigt + ↓ +Mehr Leser, mehr Wert für nächsten Boost-Käufer +``` + +### Voraussetzungen für Funktionieren + +- **Tools müssen tatsächlich gut sein.** Wenn das KI-Lektorat schlechter ist als das, was der Publisher selbst zustande bringt, kollabiert der Loop. → Tool-Qualität ist Wettbewerbsvorteil, hier wird investiert. +- **Score-Verbesserung muss spürbar und nachvollziehbar sein.** Der Publisher muss verstehen, was sein Tool-Einsatz konkret gebracht hat. → Score-Breakdown sichtbar, Vorher-Nachher-Vergleich. +- **Reichweite muss real sein.** Ein gekaufter Top-Slot muss tatsächlich Reichweite bringen. → Leser-Seite (Newsletter, SEO, Social) muss aktiv aufgebaut werden. +- **Boost-Schwellen dürfen nicht zu hoch sein.** Sonst wird der Loop frustrierend statt motivierend. → Schwellen iterativ kalibrieren auf Basis realer Score-Verteilung. + +### Was das für den Build bedeutet + +- **Tools haben strategische Priorität.** KI-Lektorat, Pressetext-Optimierung, Headline-Booster sind nicht nur Add-ons, sondern das Herzstück der Wertschöpfung. +- **Score-Anzeige muss früh implementiert werden.** Ohne sichtbaren Score kein Loop. +- **Statistik-Dashboard ist Pflicht für mittlere Tarife.** Ohne sichtbare Reichweiten-Daten erkennen Publisher den Wert ihres Investments nicht. + +--- + +## 13. Datenmodell-Skizze – Ergänzungen + +Zusätzlich zu den bestehenden Tabellen aus dem Hauptkonzept: + +``` +content_scores + - id, press_release_id + - score (0-100), version (bei Neuberechnung) + - factors (JSON: pressestil, struktur, lesbarkeit, vollstaendigkeit, + bildmaterial, quellen, headline, originalitaet) + - calculated_at, calculation_reason (initial/edit/tool_applied) + +placements + - id, press_release_id, account_id + - slot_type (kategorie_highlight, startseite_highlight, top_slot, + newsletter, social_share, verteiler_klein/mittel/gross) + - starts_at, ends_at + - credits_spent + - status (scheduled, active, completed, cancelled) + - eligibility_check_passed (bool, snapshot bei Buchung) + - eligibility_score_snapshot (Content-Score zum Zeitpunkt der Buchung) + - created_at + +placement_inventory + - id, slot_type + - max_concurrent (z.B. 1 für Top-Slot, 3 für Startseite-Highlight) + - duration_options (JSON: [24h, 72h]) + - min_content_score (75) + - min_classification ('green') + +trust_score_log + - id, account_id + - score (0-100), tier (bronze/silber/gold/verifiziert) + - factors (JSON: pm_count, avg_content_score, complaints, + account_age_days) + - calculated_at + +accounts (Ergänzungen) + - + trust_score (int, 0-100) + - + trust_tier (enum) + - + verified_business_profile (bool) + - + verified_at +``` + +Wichtige Logiken: + +- **placement_inventory** definiert, wie viele Slots welcher Art parallel verfügbar sind. Bei Buchung wird geprüft: ist ein Slot für das gewünschte Zeitfenster frei? Wenn nicht: nächstmöglicher Termin anbieten oder ablehnen. +- **eligibility_score_snapshot** auf Placement-Ebene: damit nachvollziehbar bleibt, mit welchem Score eine PM zum Buchungszeitpunkt qualifiziert war. Wenn der Score später sinkt (etwa durch Korrektur), bleibt der gebuchte Slot bestehen, aber bei Verlängerung wird neu geprüft. +- **content_scores** mit Versionierung erlaubt nachträglich Auswertung: Welche Tools haben welchen Score-Effekt gehabt? Daten für Tool-Optimierung. + +--- + +## Offene Punkte / nächste Entscheidungen (Update) + +Zusätzlich zu den bereits dokumentierten Punkten: + +- **Content-Score-Faktoren feinjustieren:** Welche Gewichtung passt für deutsche Pressemitteilungen? Iterativ kalibrieren mit echten Daten. +- **Boost-Schwellen kalibrieren:** Erst nach 100–200 echten PMs sehen, wo die Score-Verteilung liegt. Schwellen ggf. anpassen. +- **Trust-Score: User vs. Firma:** Empfehlung Firma, aber Detail-Logik bei Mitarbeiterwechsel klären. +- **Tool-Qualität:** KI-Prompts für Lektorat und Pressetext-Optimierung müssen sehr sauber gebaut werden. Eigene Test-Suite mit Vorher/Nachher-PMs. +- **Slot-Inventory:** Wie viele Top-Slots parallel? Empfehlung 1 (sonst verliert er an Wert), Startseite-Highlight 3, Kategorie-Highlight 5–10 je Branche. +- **Editorial-Picks:** Wer wählt aus? Anfangs du selbst, später ggf. Redaktions-Account mit Frontend-Tool. \ No newline at end of file diff --git a/docs/konzept/Konzept-Update 2 – Score-Stufen-System.md b/docs/konzept/Konzept-Update 2 – Score-Stufen-System.md new file mode 100644 index 0000000..4fcfd2c --- /dev/null +++ b/docs/konzept/Konzept-Update 2 – Score-Stufen-System.md @@ -0,0 +1,203 @@ + + +Stand: Mai 2026 Zweck: Ersetzt die Außenkommunikation des Content-Scores durch ein dreistufiges System. Aktualisiert Abschnitt 15.2 (Content-Score) und Abschnitt 16 (Boost-Eligibilität) aus dem ersten Konzept-Update. + +> **IST-Stand 11.06.2026**: Der Content-Score mit Stufen-System ist +> **umgesetzt** (siehe `docs/user-admin/Entwicklungsplan KI-Pruefung und +> Veroeffentlichung.md`, Phase 5): `content_score`/`content_tier` auf +> `press_releases`, Schwellen Geprueft ≥ 60 / Hochwertig ≥ 80 kalibrierbar +> in `config/scoring.php`, Editor-Score-Panel, Admin-Badges und +> oeffentliches Stufen-Badge in der Customer-Ansicht (Standard ohne Badge). +> Offen: Stufen-Badges im oeffentlichen Web-Frontend, Boost-Eligibilitaet +> nach Stufe (zum Launch laut Decision-Update: Boost nur fuer gruene PMs) +> sowie User-/Firmen-Trust-Score (Phase 3). + +--- + +## Hintergrund + +Der Content-Score (0–100) bleibt als plattform-internes Steuerungsinstrument bestehen. Für die Außenkommunikation gegenüber Lesern wird die konkrete Punktzahl jedoch durch ein dreistufiges Stufen-System ersetzt. Gründe: + +- Konkrete Punktzahlen wirken meta-fetischistisch und lenken vom Inhalt ab +- Vergleichbarkeit zwischen PMs ("warum nur 67?") erzeugt Konflikte ohne Mehrwert +- Goodhart's Law: Publisher würden auf die Zahl optimieren statt auf Qualität +- Stufen sind kulturell etabliert (Nutri-Score, Stiftung Warentest) und sofort verständlich + +Die Punktzahl bleibt dort erhalten, wo sie produktiv ist: im Editor während des Schreibens, im Publisher-Dashboard und in Boost-Buchungs-Dialogen. + +--- + +## 15.2 Content-Score (überarbeitet) + +### Score und Stufen + +Der Content-Score (0–100) wird Plattform-intern unverändert berechnet. Für alle nach außen gerichteten Anzeigen gilt folgendes Mapping: + +|Stufe|Score-Bereich|Bedeutung| +|---|---|---| +|**Standard**|30 – 59|Mindestqualität erreicht, regulär veröffentlicht| +|**Geprüft**|60 – 79|Solide Pressemitteilung, gute Substanz, redaktionelle Standards eingehalten| +|**Hochwertig**|80 – 100|Top-Qualität, redaktioneller Maßstab| + +PMs unterhalb von Score 30 werden vom Klassifikations-Score (Grün/Gelb/Rot) abgefangen und entweder in die manuelle Review-Queue gegeben oder zurück an den Autor verwiesen. Auf der öffentlichen Plattform sind ausschließlich PMs ab Stufe Standard sichtbar. + +Schwellenwerte sind als Anker zu verstehen und werden nach 100–200 echten PMs anhand der tatsächlichen Score-Verteilung kalibriert. + +### Sichtbarkeit pro Stufe + +Nicht jede Stufe wird gleich behandelt. Standard wird nicht beworben – weder als Badge noch als Label. Erst ab Stufe Geprüft erscheint sichtbar ein Vertrauensindikator. So wirkt das System nicht wie ein Stigma für Standard-PMs, sondern wie eine Auszeichnung für die besseren. + +|Stufe|Auf Detailseite|In Listen|Im Newsletter-Filter| +|---|---|---|---| +|Standard|nichts angezeigt|nichts angezeigt|enthalten in "alle Meldungen"| +|Geprüft|Häkchen-Icon mit Tooltip "Geprüfte Pressemitteilung"|optional kleines Häkchen-Icon|"Geprüfte Pressemitteilungen"| +|Hochwertig|Label "Hochwertig" mit Stern-Icon|Stern-Icon neben Headline|"Hochwertige Pressemitteilungen"| + +### Wo die Punktzahl sichtbar bleibt + +Die genaue Punktzahl bleibt produktiv im geschützten Bereich: + +**Im Editor während des Schreibens:** + +> _"Aktueller Score: 67/100 – Stufe: Geprüft. Noch 13 Punkte bis 'Hochwertig'. So verbessern Sie Ihre Pressemitteilung:_ _• Bild hinzufügen (+5–10 Punkte)_ _• Zitat einbauen (+3–5 Punkte)_ _• Lead-Absatz präziser fassen (+2–4 Punkte)"_ + +**Im Publisher-Dashboard:** + +- Score pro PM mit Stufenanzeige +- Durchschnittsscore über alle PMs +- Trend über Zeit +- Score-Breakdown nach Faktoren + +**In Boost-Buchungs-Dialogen:** + +> _"Der Top-Slot Startseite erfordert Stufe 'Hochwertig' (Score 80+). Ihre Pressemitteilung erreicht aktuell Stufe 'Geprüft' (Score 67). So erreichen Sie 'Hochwertig':_ _[Pressetext-Optimierung – 15 Credits, +15–20 Punkte]_ _[Headline-Booster – 5 Credits, +3–7 Punkte]"_ + +--- + +## 16. Boost-Eligibilität (überarbeitet) + +Boost-Schwellen werden von konkreten Punktzahlen auf Stufen umgestellt. Die Schwellen werden Plattform-intern weiterhin auf Score-Ebene geprüft, kommuniziert wird gegenüber Publishern aber die Stufe. + +### Schwellenwerte je Slot-Typ + +|Slot|Klassifikation|Mindeststufe|Entspricht Score| +|---|---|---|---| +|Highlight Kategorie|Grün|Standard|≥ 30| +|Startseite-Highlight (24h / 3T)|Grün|Geprüft|≥ 60| +|Top-Slot Startseite|Grün|Hochwertig|≥ 80| +|Newsletter-Erwähnung|Grün|Geprüft|≥ 60| +|Social-Share (offizieller Kanal)|Grün|Geprüft|≥ 60| +|Verteiler-Versand (extern)|Grün|Hochwertig|≥ 80| + +PMs mit Klassifikation Gelb können nicht boostbar werden, auch nicht nach manueller Freigabe – sie bleiben in regulärer Sichtbarkeit. PMs mit Klassifikation Rot werden nicht veröffentlicht. + +### UI-Logik beim Buchen + +Wenn ein User einen Boost-Slot bucht, dessen Schwelle seine PM nicht erreicht, sieht er statt des Buchungsformulars die Tool-Empfehlungen aus dem Editor-Dialog. Der konkrete Score wird hier sichtbar, weil der Publisher die Distanz zur nächsten Stufe verstehen muss, um eine wirtschaftliche Entscheidung zu treffen (Tool kaufen oder anders boosten). + +--- + +## Differenzierung: Hochwertig-Stufe vs. Editorial-Pick + +Beide sind Vertrauenssignale, aber konzeptionell unterschiedlich. Sie müssen visuell und sprachlich klar unterscheidbar sein. + +||Hochwertig-Stufe|Editorial-Pick| +|---|---|---| +|**Vergabe**|algorithmisch (Score ≥ 80)|manuell durch Redaktion| +|**Voraussetzung**|Content-Score|freie redaktionelle Auswahl| +|**Häufigkeit**|viele PMs|wenige, ausgewählte PMs| +|**Bezeichnung**|"Hochwertig"|"Auswahl der Redaktion"| +|**Symbol**|Stern-Icon|orange Auszeichnung (wie auf Startseite)| +|**Funktion**|Qualitätsindikator|redaktionelle Empfehlung| + +Eine PM kann beide Auszeichnungen gleichzeitig tragen. In der Regel werden Editorial-Picks aus dem Pool der Hochwertig-PMs gewählt, aber das ist nicht zwingend – die Redaktion hat freie Hand. + +--- + +## Außenkommunikation – Konkrete Labels und Texte + +Damit das System Plattform-weit konsistent kommuniziert wird, hier die verbindlichen Texte und Labels: + +### Detailseite – Metadaten-Zeile + +Statt bisher "Qualität: Sehr hoch (94)": + +- **Standard:** kein Label +- **Geprüft:** Häkchen-Icon ✓ mit Tooltip "Geprüfte Pressemitteilung – redaktionelle Standards eingehalten" +- **Hochwertig:** Stern-Icon ★ mit Label "Hochwertig" und Tooltip "Pressemitteilung mit besonders hoher Qualität" + +### Newsletter-Block + +Statt "Nur Meldungen ab Content-Score 80": + +- **Tageszusammenfassung:** "Alle wichtigen Meldungen aus DACH" +- **Wochenrückblick:** "Die wichtigsten Meldungen der Woche" +- **Branchen-Alerts:** "Höchstens 2 Mails pro Woche. Nur hochwertige Pressemitteilungen aus dieser Branche." + +### Erweiterte Suche – Filter + +Filter-Optionen: + +- "Alle Pressemitteilungen" +- "Nur geprüfte Pressemitteilungen" +- "Nur hochwertige Pressemitteilungen" +- "Nur Auswahl der Redaktion" + +### Vertrauens-Sektion auf der Plattform + +Eine Erklärseite (z.B. /redaktion oder /qualitaet) erläutert das System öffentlich: + +> _"Jede Pressemitteilung auf businessportal24 wird automatisch auf Qualität geprüft. Wir unterscheiden drei Stufen:_ +> +> _• **Standard** – erfüllt unsere Mindestanforderungen für Pressemitteilungen_ _• **Geprüft** – solide Pressemitteilung mit guter Substanz_ _• **Hochwertig** – Pressemitteilung in redaktioneller Spitzenqualität_ +> +> _Zusätzlich vergibt unsere Redaktion das Sigel 'Auswahl der Redaktion' für Pressemitteilungen, die wir besonders empfehlen."_ + +--- + +## Auswirkungen auf das Datenmodell + +Geringe Änderungen, da Score weiterhin intern als Zahl gespeichert wird: + +``` +press_releases (Ergänzung) + - + content_tier (enum: standard, gepruft, hochwertig) + – wird automatisch aus content_score abgeleitet + – kann als generated column oder per Trigger gepflegt werden + - + editorial_pick (bool, default false) + - + editorial_pick_at, editorial_pick_by (für Audit) +``` + +Die Stufen-Schwellen werden in einer zentralen Konfiguration gepflegt (z.B. `config/scoring.php` in Laravel), damit sie bei späterer Kalibrierung an einer Stelle anpassbar sind, ohne Code-Änderungen. + +--- + +## Was unverändert bleibt + +- **Content-Score (0–100)** wird intern weiterhin berechnet und gespeichert +- **Score-Faktoren und Gewichtungen** bleiben wie in Abschnitt 15.2 des ersten Updates definiert +- **Klassifikations-Score (Grün/Gelb/Rot)** bleibt unverändert als Eintritts-Filter +- **Trust-Score** auf Account-Ebene bleibt unverändert +- **Tool-zu-Algorithmus-Loop** funktioniert identisch, nur mit Stufen statt Punktzahlen in der Außenkommunikation +- **Datenmodell** für `content_scores`, `placements`, `placement_inventory` bleibt unverändert + +--- + +## Migrationsschritt für bestehendes Mockup + +Die Detailseite-Mockup-Version vom 7. Mai zeigt aktuell "Qualität: Sehr hoch (94)" in der Metadaten-Zeile. Anpassung: + +- Text "Qualität: Sehr hoch (94)" entfernen +- Stattdessen: Stern-Icon ★ + Label "Hochwertig" (für Stufe Hochwertig) +- Tooltip beim Hover über das Label +- Konsistenz prüfen mit Newsletter-Sidebar-Block (auch dort Score-Zahl entfernen, durch "hochwertige Pressemitteilungen" ersetzen) + +--- + +## Offene Punkte / nächste Entscheidungen + +- **Schwellenwerte kalibrieren:** Erst nach 100–200 echten PMs sehen, wo die Score-Verteilung liegt. Schwellen ggf. anpassen, sodass Standard ca. 40–50 %, Geprüft ca. 35–45 %, Hochwertig ca. 10–20 % der PMs umfasst (Anhaltswert). +- **Visuelle Symbole final wählen:** Stern für Hochwertig, Häkchen für Geprüft – Alternativen prüfen (z.B. Medaille, Auszeichnungs-Symbol). Konsistenz mit bestehendem Icon-Set wahren. +- **Editorial-Pick-Symbol final festlegen:** Orange Auszeichnung wurde auf Startseite genutzt – muss klar unterscheidbar bleiben vom Hochwertig-Symbol. +- **Tooltip-Texte feinjustieren:** kurze, prägnante Erklärung pro Stufe, übersetzungsfähig. +- **Erklärseite /redaktion oder /qualitaet:** als Vertrauens-Anker für Leser und Suchmaschinen erstellen. \ No newline at end of file diff --git a/docs/konzept/Konzept-Update 3 – Multi-Brand-Architektur (Hub & Spoke).md b/docs/konzept/Konzept-Update 3 – Multi-Brand-Architektur (Hub & Spoke).md new file mode 100644 index 0000000..5c25c23 --- /dev/null +++ b/docs/konzept/Konzept-Update 3 – Multi-Brand-Architektur (Hub & Spoke).md @@ -0,0 +1,327 @@ + + +Stand: Mai 2026 Zweck: Festlegung der Plattform-Architektur mit zentralem Hub (presseportale.com) und mehreren öffentlichen Brand-Portalen. Dokumentation der strategischen, technischen und Branding-Entscheidungen für die Entwicklung. + +--- + +## Architektur-Entscheidung + +Die Plattform folgt einem **Hub-and-Spoke-Modell**: + +- **Ein zentraler Hub** für alle Verwaltungs-, Veröffentlichungs- und Abrechnungs-Funktionen +- **Mehrere öffentliche Brand-Portale**, die unabhängige redaktionelle Marken mit eigener Zielgruppe und eigener Editorial-Anmutung darstellen +- **Eine gemeinsame Codebase und Datenbank** im Hintergrund + +Das Modell entspricht der Architektur, mit der etablierte Verlagsgruppen (Axel Springer, Hubert Burda Media) ihre Markenportfolios technisch organisieren: ein zentrales Redaktions- und Tool-System, viele Frontend-Marken. + +### Strategische Vorteile + +- **Effiziente Entwicklung:** Eine Codebase für alle Portale, Updates an Tools/Editor/Credit-System wirken portal-übergreifend +- **Multi-Portal-Publishing:** Publisher können Pressemitteilungen mit einem Klick auf mehreren Portalen veröffentlichen (verkaufbares Premium-Feature) +- **Datenkonsistenz:** Ein Account, ein Credit-Stand, eine Statistik-Aggregation über alle Portale +- **Skalierbarkeit:** Weitere Portale (z.B. branchenspezifisch) können als reine Frontend- und Konfigurations-Erweiterung hinzugefügt werden +- **Saubere Trennung Marke vs. Funktion:** Brand-Portale optimieren für Editorial-Glaubwürdigkeit, Hub für Funktion und Effizienz + +--- + +## Domain- und Verantwortungs-Aufteilung + +### presseportale.com – Hub (Publisher Hub) + +**Marke:** Publisher Hub (mit Subline "Mein Pressekonto") + +**Funktion:** + +- User-Panel: Editor, Dashboard, Statistiken, Credit-Verwaltung, Newsroom-Konfiguration +- Admin-Panel: Review-Queue, Moderation, Inventory-Verwaltung, Editorial-Picks, User-Verwaltung +- Magic-Link-Bereich für Pressekontakte +- Account-Setup und Tarif-Auswahl (zentraler Veröffentlichungs-Funnel) +- Stripe-Integration, Rechnungen, Buchhaltung +- Alle KI-Tools (Lektorat, Pressetext-Optimierung, Bildgenerierung, etc.) + +**Branding:** Neutrale, eigenständige Marke. Sauberes Tool-Design (aktuell Flux UI). Funktionsfokussiert. Brand-Kontext der Portale wird über Banner/Hinweise vermittelt, nicht über Farbadaption. + +**Zielgruppe:** Publisher (Unternehmen, PR-Agenturen, Pressekontakte), Admins. + +--- + +### businessportal24.com – Brand-Portal + +**Marke:** businessportal24 + +**Funktion:** + +- Öffentliche Pressemitteilungs-Plattform +- Editorial-Anmutung mit Fokus auf Wirtschaft, B2B, Branchen-Tiefe +- Kein eigener User-Login-Bereich +- Reichweiten-Generierung über Newsletter, SEO, Branchenseiten + +**Branding:** Eigenständige Editorial-Identität (Anthrazit + Orange-Akzent). Wirkt wie ein Wirtschaftsmedium, nicht wie ein Pressedienst. + +**Zielgruppe:** Wirtschaftsjournalisten, Mediaplaner, B2B-Entscheider, Branchen-Experten. + +--- + +### presseecho.de – Brand-Portal + +**Marke:** presseecho + +**Funktion:** Wie businessportal24, aber mit anderer Markenidentität und potentiell anderer Zielgruppen-Ausrichtung. Konzeption folgt in eigener Iteration. + +**Branding:** Eigenständig, klar unterscheidbar von businessportal24 (eigener Akzentton, eventuell anderer Editorial-Schwerpunkt). + +**Zielgruppe:** noch zu definieren / komplementär zu businessportal24. + +--- + +## Login- und Session-Mechanik + +### Grundregel + +**Alle Authentifizierung läuft ausschließlich über presseportale.com.** Brand-Portale haben keine eigenen Login-Formulare oder Auth-Bereiche. + +### Verhalten in den Brand-Portalen + +- "Anmelden"-Button im Header eines Brand-Portals → Redirect auf presseportale.com (Login-Seite mit Rück-Redirect nach erfolgreicher Anmeldung) +- "Veröffentlichen"-Button → kurzer Brand-spezifischer Landing-Inhalt, dann Übergang auf presseportale.com für Account-Setup und Veröffentlichungs-Funnel +- Wenn der User über die Brand-Domain eingeloggt erkannt wird (über Cross-Domain-Mechanismus, siehe unten), erscheint im Header ein "Mein Account"-Element, das auf presseportale.com führt + +### Cross-Domain-Session + +Da die Brand-Portale und der Hub auf unterschiedlichen Top-Level-Domains laufen (.com / .de), ist Cookie-basiertes Session-Sharing nicht möglich. Notwendige Lösung: + +**Variante A (empfohlen):** Token-basierter Auth-Mechanismus über Laravel Sanctum + +- Hub gibt nach Login ein API-Token aus +- Brand-Portale prüfen über API gegen den Hub den Login-Status +- Vorteil: sauber, standardkonform, skalierbar + +**Variante B:** Lightweight-Cookie-Sync über Cross-Domain-Redirect bei Pageload (analog zu wie Single-Sign-On-Lösungen es machen) + +- Komplexer, fehleranfälliger +- Nur zu empfehlen, falls Variante A nicht umsetzbar + +Empfehlung: Variante A, da sie zum Laravel-Stack ohnehin passt und auch für die API-Anbindung von Distribution-Partnern (connektar etc.) wiederverwendbar ist. + +### Magic-Link-Flow + +Magic-Links für Pressekontakte zeigen _immer_ auf presseportale.com mit Brand-Kontext-Hinweis: + +> _„Sie verwalten eine Pressemitteilung, die auf businessportal24 veröffentlicht wurde."_ + +Brand-Logo und -Name werden im Hub-Header angezeigt, damit der Pressekontakt versteht, wo seine PM erscheint. Funktional bleibt der Flow zentral. + +--- + +## E-Mail-Strategie + +Saubere Trennung nach Funktion: + +### System-Mails (von presseportale.com) + +- Login, Password-Reset, Account-Bestätigung +- Magic-Link für Pressekontakte +- Stripe-Zahlungs-Bestätigungen, Rechnungen +- Credit-Aufladung-Bestätigungen, Auto-Refill-Hinweise +- Wartungs-/System-Benachrichtigungen + +**Absender:** `noreply@presseportale.com` oder `support@presseportale.com` + +### Editorial- und Brand-Mails (von der jeweiligen Brand-Domain) + +- "Ihre Pressemitteilung wurde auf businessportal24 veröffentlicht" +- Branchen-Newsletter (Tageszusammenfassung, Wochenrückblick, Branchen-Alerts) +- Newsroom-Update-Benachrichtigungen +- Reichweiten-Statistiken pro PM + +**Absender:** `redaktion@businessportal24.com`, `newsletter@businessportal24.com`, analog für presseecho.de + +### Begründung + +Diese Trennung stärkt das Branding der Portale (Editorial-Mails kommen "vom Magazin"), während Verwaltungs-Funktionen klar dem zentralen Hub zugeordnet bleiben. Funktional sind beide Domains technisch identisch hinterlegt (gleicher Mail-Service, gleiche Templates), nur die Absender-Konfiguration unterscheidet sich. + +--- + +## Datenmodell-Implikationen + +Die Hub-and-Spoke-Architektur erfordert eine zentrale Tabelle für die Brand-/Portal-Zugehörigkeit: + +``` +brands (oder portals) + - id, slug, name, domain + - primary_color, accent_color + - logo_url + - editorial_email, newsletter_email + - is_active, created_at + +press_releases (Ergänzung) + - + brand_id (FK auf brands) + - + cross_published_to (JSON array of brand_ids, für Multi-Portal-Veröffentlichung) + +placements (Ergänzung) + - + brand_id (FK auf brands) + - Placements gelten pro Portal, da Inventory portal-spezifisch ist + +newsletters (Ergänzung) + - + brand_id + +accounts + - Bleiben portal-übergreifend + - Eine Person hat einen Account, kann auf allen Portalen veröffentlichen + - Sub-Berechtigungen pro Brand möglich (für Agency-Tarif: Marke X nur auf Portal Y) + +credit_accounts + - Bleiben portal-übergreifend, ein Credit-Pool für alles +``` + +### Wichtige Logiken + +- Pressemitteilungen sind immer einem Brand zugeordnet (`brand_id`), können aber zusätzlich auf andere Brands "cross-published" werden +- Branchen, Kategorien und Tags können entweder portal-spezifisch oder portal-übergreifend definiert sein – Empfehlung: portal-übergreifender Stamm, mit Möglichkeit zur portal-spezifischen Untergliederung +- Placements/Boost-Slots sind grundsätzlich portal-spezifisch (jeder Top-Slot auf businessportal24 ist ein anderer Slot als auf presseecho) +- Aggregierte Statistiken im Dashboard zeigen alle Brands eines Publishers zusammen, mit Filter-Möglichkeit pro Brand + +--- + +## Brand-übergreifende Features + +### Aktuell (Migrations-Phase) + +Im Dashboard werden Pressemitteilungen aktuell **gefiltert nach Portal** angezeigt. Hintergrund: Die beiden Portale kommen aus separater Legacy-Entwicklung, eine getrennte Sicht erleichtert die Migration. + +### Mittelfristig + +**Cross-Sichtbarkeit als Standard:** + +- Dashboard zeigt alle PMs eines Publishers portal-übergreifend +- Filter-Möglichkeit pro Portal vorhanden, aber nicht Standard +- Statistiken aggregieren über alle Portale + +**Multi-Portal-Veröffentlichung als Premium-Feature:** + +- Beim Einreichen einer PM kann der Publisher auswählen, auf welchen Portalen sie erscheinen soll +- Standard: Hauptportal des Publishers +- Optional: Zusatz-Portale (kostet zusätzliche Credits oder ist in höheren Tarifen inkludiert) +- Wettbewerber können das nicht – starkes Verkaufsargument + +**Portal-übergreifender Newsroom:** + +- Premium-Publisher haben einen Newsroom auf jedem Portal, gepflegt aus einem zentralen Profil im Hub +- Logo, Beschreibung, Kontakt einmal pflegen – auf allen Portalen aktuell + +--- + +## Implikationen für die Veröffentlichen-Landingpage + +Aus der Architektur folgt die Aufteilung der Veröffentlichen-Seite in zwei Ebenen: + +### Auf businessportal24.com/veroeffentlichen (Brand-Landing) + +- Hero mit _brand-spezifischem_ Wertversprechen (Wirtschafts-Fokus, B2B-Reichweite, Branchen-Tiefe) +- Konkrete Reichweiten-Zahlen _für businessportal24_ (Newsletter-Abos, Branchen-Traffic, ISIN-Coverage) +- Differenzierungs-Punkte speziell für dieses Portal +- Tarif-Teaser ("Ab 19 € pro Pressemitteilung", mit Link auf volle Übersicht) +- Soziale Beweise (Logos von Publishern, die businessportal24 nutzen) +- Direkter CTA "Jetzt veröffentlichen →" → zur zentralen Plattform auf presseportale.com + +### Auf presseportale.com/veroeffentlichen (Zentrale Funnel-Seite) + +- Volle Tarif-Tabelle (Einzel / Starter / Business / Pro / Agency) +- Tool-Showcase (Lektorat, KI-Bilder, etc.) +- Erklärung des Multi-Portal-Konzepts ("Ein Account, mehrere Portale") +- Portal-Auswahl im Anmelde-Prozess ("Auf welchen Portalen möchten Sie veröffentlichen? businessportal24 / presseecho / beide") +- FAQ +- Account-Setup-Funnel + +### Analoge Struktur für presseecho.de + +presseecho.de/veroeffentlichen folgt demselben Pattern, mit brand-spezifischem Inhalt und Übergang zum zentralen Funnel. + +--- + +## Footer-Verlinkung zwischen Hub und Brand-Portalen + +### Auf Brand-Portalen (Footer) + +Im Footer-Bereich von businessportal24 und presseecho ein dezenter Link: + +> **Für Publisher** → Publisher Hub | Pressemitteilung einreichen | Tarife & Pakete + +Bestehende Kunden steigen so direkt in den Hub ein, ohne sich die Hub-Domain merken zu müssen. + +### Auf dem Hub (Footer) + +Im Footer von presseportale.com Verlinkung auf alle Brand-Portale: + +> **Unsere Portale** → businessportal24 (Wirtschaft & Branchen) | presseecho (...) + +Schafft Transparenz und Vertrauen – sichtbar, dass es eine Plattform-Familie ist. + +--- + +## Branding-Strategie pro Bereich + +|Bereich|Primärfarbe|Anmutung|Charakter| +|---|---|---|---| +|**presseportale.com**|neutral (z.B. Anthrazit + zurückhaltender Akzent)|Tool / SaaS|sachlich, funktional, effizient| +|**businessportal24.com**|Anthrazit + Orange|Editorial / Wirtschaftsmedium|seriös, datendicht, B2B| +|**presseecho.de**|eigene Palette (z.B. Anthrazit + Blau oder Bordeaux)|Editorial / anderer Schwerpunkt|klar differenziert von businessportal24| + +Wichtig: Die Brand-Portale müssen optisch klar unterscheidbar sein, damit Leser nicht den Eindruck gewinnen, es sei "dasselbe in zwei Versionen". Empfehlung: gemeinsame typografische Sprache (Editorial-Serif für Headlines, gleiche Sans für UI), aber klar unterschiedliche Akzentfarben und sekundäre visuelle Elemente. + +Der Hub bekommt eine _eigene_ visuelle Sprache, die sich von beiden Brand-Portalen abhebt. Tendenziell: weniger Farbe, mehr Whitespace, Tool-orientierte Komponenten (Tabellen, Dashboards, Form-Bausteine). Aktuell auf Flux UI basierend – soll noch eigene Identität bekommen, ohne den Funktions-Charakter zu verlieren. + +--- + +## Migration und Roadmap + +### Phase 1 (laufend) + +- Backend-Migration zu Laravel +- Zentrale `brands`-Tabelle als Grundlage anlegen +- Bestehende PMs der beiden Portale werden mit `brand_id` versehen +- Dashboard zeigt portal-getrennt (Migrations-Komfort) + +### Phase 2 + +- Hub auf presseportale.com mit User-Panel produktiv +- Magic-Link-Flow zentral aufgesetzt +- Erste Brand-Portal-Iteration (businessportal24) im neuen Design live +- Cross-Domain-Auth über Sanctum + +### Phase 3 + +- Zweites Brand-Portal (presseecho) im neuen Design live +- Cross-Sichtbarkeit im Dashboard +- Multi-Portal-Veröffentlichung als Feature aktiv + +### Phase 4 (mittelfristig) + +- Aggregierte Statistiken portal-übergreifend +- Portal-übergreifender Newsroom +- Vorbereitung für drittes Portal (falls relevant) + +--- + +## Skalierungs-Argument + +Die Architektur ist explizit auf Wachstum ausgelegt. Wenn in 12–24 Monaten ein drittes Portal sinnvoll wird (z.B. ein branchenspezifisches wie "energieportal.com" oder ein regional fokussiertes wie "presseportal-bayern.de"), bedeutet das technisch: + +- Keine neue Codebase +- Keine neue Datenbank +- Kein neuer Auth-Mechanismus +- Kein neues Credit-System +- Nur: Frontend, Konfiguration in `brands`-Tabelle, Editorial-Setup + +Damit wird die Investition in den Hub heute strukturell verteidigt – jede Stunde, die in den Hub investiert wird, zahlt auf jedes zukünftige Portal mit ein. + +--- + +## Offene Punkte / nächste Entscheidungen + +- **Cross-Domain-Auth final festlegen:** Sanctum-Implementierung details, Token-Lebensdauer, Refresh-Strategie +- **presseecho-Konzept:** Markenidentität, Zielgruppe, Differenzierung zu businessportal24 klären +- **Multi-Portal-Veröffentlichung – Pricing:** wie wird Cross-Publishing verrechnet? Pro zusätzlichem Portal X Credits? In höheren Tarifen inkludiert? Wie sehen die Tier-Grenzen aus? +- **Hub-Design eigenständig schärfen:** aktuelles Flux-UI-Setup soll mehr Eigenständigkeit bekommen, ohne den funktionalen Charakter zu verlieren – eigenes Mini-Briefing nötig +- **Footer-Verlinkungen zwischen den Portalen:** konkrete Texte und Platzierungen finalisieren +- **Übergang in den Brand-Portalen markieren:** wenn ein User von businessportal24 auf den Hub springt – sichtbarer Übergangs-Indikator (Banner oben "Sie sind im Publisher Hub" mit Rück-Link)? Oder unauffällig? UX-Entscheidung steht aus \ No newline at end of file diff --git a/docs/konzept/Konzept-Update 4 - Positionierung + Markenversprechen.md b/docs/konzept/Konzept-Update 4 - Positionierung + Markenversprechen.md new file mode 100644 index 0000000..4389706 --- /dev/null +++ b/docs/konzept/Konzept-Update 4 - Positionierung + Markenversprechen.md @@ -0,0 +1,178 @@ + + +**Datum:** 12. Mai 2026 **Status:** Strategie-Dokument **Vorgänger:** Konzept-Updates 1 (Score-Architektur), 2 (Score-Stufen), 3 (Multi-Brand-Architektur) **Bezug:** Presseportal-Strategie (lebendes Strategiepapier, Mai 2026) + +--- + +## 1. Ausgangslage + +Die Multi-Brand-Architektur (Update 3) hat die technische Trennung von Hub und Brand-Portalen geklärt. Offen blieb die inhaltliche Frage: **Wofür stehen die beiden Marken konkret – und was dürfen sie versprechen?** + +Frühere Positionierungs-Entwürfe haben Versprechen formuliert, die in einem Solo-Entwicklungs-Setup nicht eingehalten werden können: + +- „Exklusive Analysen, Interviews, kuratierte Nischen-Informationen" für presseecho.de – setzt redaktionelle Arbeit voraus, die nicht geleistet wird. +- „Breite öffentliche Wahrnehmung und Lead-Generierung" für businessportal24.com – ist ein Reichweiten-Versprechen, mit dem große Player mit ganz anderen Budgets vermarkten. + +Beide Formulierungen folgen dem Muster „großes Presseportal kopiert" – genau das Anti-Pattern, das die übergeordnete Strategie ausschließt. Die Positionierung muss aus dem entstehen, was tatsächlich anders gemacht wird: Archiv-Stabilität, Qualitätsschwelle, faire Konditionen, Themen-Tiefe statt Timeline-Hype. + +**Zusätzliche Einschränkung:** Beide Portale ziehen am Start aus demselben Migrations-Pool. Eine inhaltliche Differenzierung über getrennte Redaktionen oder exklusive Inhalte ist am Start nicht ehrlich darstellbar. + +## 2. Leitidee: Differenzierung über Leseparadigma + +Da die Inhalte zu Beginn identisch sind, wird die Differenzierung **nicht über das Was, sondern über das Wie** aufgebaut: + +||businessportal24|presseecho| +|---|---|---| +|**Leitfrage**|„Was ist aktuell bei Unternehmen los?"|„Was läuft in dieser Branche / zu diesem Thema?"| +|**Logik**|Aktualitäts-/Zeitachsen-Logik|Themen-/Cluster-/Archiv-Logik| +|**Lesemodus**|„Was ist neu?"|„Was gibt es dazu?"| +|**Wert für Leser**|Übersicht über aktuelle Geschehnisse|Tiefe und Kontext zu einem Thema| +|**Wert für Publisher**|Sichtbarkeit _jetzt_|Auffindbarkeit _dauerhaft_| + +Diese Differenzierung ist: + +- **Solo-tauglich**: eine Codebasis, ein Inhalts-Pool, zwei Präsentations-Logiken +- **Ehrlich**: beide Portale liefern echten Mehrwert, nur für unterschiedliche Lesebedürfnisse +- **Selbstverstärkend**: mit der Zeit entscheiden Publisher selbst, welche Brand zu ihrem Inhalt passt – die Differenzierung wächst organisch + +## 3. businessportal24.com – Positionierung + +### Kern-USP + +> _„Die Wirtschaftspresse für den deutschen Mittelstand – aktuell, transparent, ohne Reichweiten-Marketing. Was hier veröffentlicht wird, ist nach Qualität geprüft und bleibt dauerhaft auffindbar."_ + +### Zielgruppen + +**Publisher-Seite:** Mittelständische Unternehmen, Selbstständige, kleinere PR-Agenturen, regionale Akteure. Charakteristisch: Sie suchen _eine_ zuverlässige Veröffentlichungs-Adresse, nicht zehn Streuverteiler. + +**Leser-Seite:** Journalisten und Multiplikatoren, die wissen wollen, was bei KMU gerade passiert; Wirtschaftsinteressierte; lokale/regionale Recherche. + +### Thematischer Schwerpunkt + +Unternehmensmeldungen, neue Produkte, Personalia, Standorte, Aufträge, Auszeichnungen, Wirtschaftsthemen aus der Breite des Mittelstands. + +### Tonalität + +Aktiv, klar, wirtschaftsnah, zugänglich. Nicht hip, aber lebendig. Erwachsen, nicht laut. + +### Farbwelt + +Energetisches Orange/Rot als Akzent auf neutraler Basis. Passt zum Profil „aktuell, lebhaft, KMU-aktiv". Wichtig: **keine SaaS-Landingpage-Gradienten**, sondern zurückhaltend eingesetzte Akzente. Das Wirtschafts-Charakter braucht typografische Dichte, keine Lifestyle-Optik. + +### Was hier _nicht_ versprochen wird + +- Reichweite oder Reichweiten-Garantien +- Lead-Generierung +- „Virale Verbreitung" +- SEO-Versprechen +- Exklusive Inhalte + +## 4. presseecho.de – Positionierung + +### Kern-USP + +> _„Das Branchenportal mit Themen-Gedächtnis – wo Pressemitteilungen nicht in der Timeline verschwinden, sondern dauerhaft in Themen- und Branchenkontexten zugänglich bleiben."_ + +### Zielgruppen + +**Publisher-Seite:** Unternehmen mit fachlich-spezifischer Kommunikation, B2B-Anbieter, Branchen-Akteure. Charakteristisch: Sie wollen, dass ihre Meldung auch in zwei Jahren noch im Themen-Kontext gefunden wird – nicht nur 48 Stunden im Strom mitschwimmen. + +**Leser-Seite:** Fachjournalisten, Branchenrecherche, Analysten, Studierende – alle, die zu einem Thema _in die Tiefe_ gehen wollen statt nur „was war heute?". + +### Thematischer Schwerpunkt + +Am Start identisch zum gemeinsamen Pool. Differenzierung über die **Präsentation**: Themen- und Branchen-Cluster, sichtbare Archivtiefe, „dazu erschien auch …", längere Lese-Pfade, Themen-Historie. + +### Tonalität + +Ruhig, sachlich, expertenorientiert – aber nicht steif. Eher „Fachbuchhandlung" als „Lifestyle-Magazin". + +### Farbwelt + +Dunkelgrün. Seriös, beständig, „Bibliotheks-Charakter". Idealer Kontrast zur aktiven Orange-Welt von businessportal24. + +### Der ehrliche Mehrwert + +Das ~10 Jahre alte Archiv mit knapp 100.000 Mitteilungen wird hier zum _Feature_, nicht zur Altlast. presseecho.de ist faktisch die Marke, in der die **Archivtiefe sichtbar** wird – und damit das Asset, das in der Strategie als wichtigster Differenzierungs-Vorteil identifiziert wurde. + +### Was hier _nicht_ versprochen wird + +- Exklusive redaktionelle Inhalte +- Eigene Interviews oder Analysen +- Kuratierte Auswahl durch eine Redaktion +- Branchen-Newsletter mit Mehrwert über die Pressemitteilungen hinaus +- Experten-Bewertungen / -Rankings + +Wenn diese Versprechen später eingelöst werden sollen, müssen sie _vorher_ operativ verfügbar sein – nicht umgekehrt. + +## 5. Markenarchitektur im Verhältnis zueinander + +Die beiden Brands sind nicht Konkurrenz, sondern **komplementär**. Beim Veröffentlichen erhält der Publisher eine ehrliche Entscheidungshilfe: + +- _„Mein Thema ist aktuell, ich will Sichtbarkeit jetzt"_ → businessportal24 +- _„Mein Thema ist fachlich, ich will dauerhafte Auffindbarkeit im Kontext"_ → presseecho +- _„Beides"_ → Cross-Publishing (gegen Aufpreis im Credit-System) + +Das erfüllt drei Funktionen gleichzeitig: + +1. **Ehrliche Beratung** gegenüber dem Kunden – keine künstliche Verknappung +2. **Verkaufslogik** für Cross-Publishing als Premium-Option +3. **Selbstverstärkende Differenzierung** – Publisher sortieren ihre Inhalte selbst zur passenden Marke + +## 6. Migrationspfad der Differenzierung + +Die Differenzierung wird **nicht zum Start vollständig** geliefert. Sie wächst in drei Phasen, im Einklang mit der Anti-Zombie-Regel „nichts versprechen, was am Start nicht verfügbar ist". + +### Phase 1 – Migration (Monate 1–6) + +Beide Portale zeigen praktisch dieselben Inhalte. Differenzierung lebt nur über: + +- **Marken-Identität**: Logo, Farbwelt, Tagline, Tonalität der Marketing-Texte +- **Navigations-Logik**: Timeline-first (businessportal24) vs. Themen-first (presseecho) +- **Landing-Page-Sprache**: unterschiedliche Wertversprechen, gleiche Backend-Mechanik + +Mehr ist in dieser Phase nicht ehrlich darstellbar. + +### Phase 2 – Aufbau (Monate 6–18) + +- Cross-Publishing wird kostenpflichtig +- Default-Publishing geht zu _einer_ Brand – Publisher entscheiden bewusst, wo es passt +- Erste organische Inhalts-Drift entsteht (regional/aktuell → businessportal24; fachlich/dauerhaft → presseecho) +- Brand-spezifische Navigations-Features werden ausgebaut (Themen-Cluster bei presseecho, Aktualitäts-Filter bei businessportal24) + +### Phase 3 – Reife (ab Monat 18+) + +- presseecho.de hat erkennbare Themenwelten mit Archivtiefe +- businessportal24.com hat erkennbares „Was-ist-neu"-Profil +- Eventuell brand-spezifische Kleinfeatures: Themen-Newsletter bei presseecho, Branchen-Filter bei businessportal24 +- Erst hier können erweiterte Markenversprechen formuliert werden – und nur, wenn sie operativ tatsächlich abgedeckt sind + +## 7. Verbindung zur Gesamtstrategie + +Diese Positionierung folgt konsequent den Leitlinien aus dem Strategiepapier: + +- **Bewusste Nische statt Konkurrenz**: kein Wettlauf mit Pressebox/openPR um Reichweite +- **Asset-Wert konservieren**: das Archiv wird über presseecho.de sichtbar gemacht statt versteckt +- **Geduld als Wettbewerbsvorteil**: Differenzierung wachsen lassen, statt am Start zu erfinden +- **Ehrliche Kommunikation**: nur versprechen, was operativ verfügbar ist +- **Konsistenz schlägt Spektakel**: ein klarer, langsam wachsender Markencharakter pro Portal + +## 8. Implikationen für die nächsten Schritte + +Daraus ergeben sich konkrete Aufgaben für die kommende Arbeitsphase: + +- **Brand-Landing businessportal24.com/veroeffentlichen** – Wertversprechen aus diesem Update, kurzer Vertriebs-Touchpoint, Übergabe in den Hub-Funnel +- **Brand-Landing presseecho.de/veroeffentlichen** – Wertversprechen aus diesem Update, betont Archiv und Themen-Kontext +- **Frontend-Navigationskonzept** – Timeline-first vs. Themen-first als sichtbarer Unterschied +- **Tonalitäts-Leitfaden** für Marketing-Texte je Brand (kann später wachsen, am Start reicht eine knappe Tabelle) +- **Cross-Publishing-Preislogik** im Credit-System (offener Punkt aus Update 3) + +## 9. Offene Punkte + +- Hub-Design-Sprache (offener Punkt aus Update 3) – sollte sich farblich/atmosphärisch klar von beiden Brand-Welten unterscheiden, neutral-funktional bleiben +- Wann genau wechselt das Default-Publishing-Modell von „zu beiden Brands" auf „zu einer Brand" (Phase-1- vs. Phase-2-Übergang) +- Soll es eine sichtbare Verbindung der beiden Marken nach außen geben (z.B. „ein Service der Pressekonto-Familie") – oder bleiben sie öffentlich strikt getrennt? + +--- + +_Dieses Update ist Teil der Reihe lebender Konzept-Dokumente. Es legt fest, was die beiden Marken am Start versprechen dürfen – und damit gleichzeitig, was sie noch nicht versprechen. Änderungen an dieser Positionierung sollten dokumentiert und mit dem Strategiepapier abgeglichen werden._ \ No newline at end of file diff --git a/docs/konzept/Konzept-X - Brand-Landing.md b/docs/konzept/Konzept-X - Brand-Landing.md new file mode 100644 index 0000000..21ab6a1 --- /dev/null +++ b/docs/konzept/Konzept-X - Brand-Landing.md @@ -0,0 +1,166 @@ +# businessportal24.com/veroeffentlichen + +**Datum:** 12. Mai 2026 **Status:** Implementierungs-Konzept **Bezug:** Konzept-Update 3 (Multi-Brand-Architektur), Konzept-Update 4 (Positionierung & Markenversprechen) + +--- + +## 1. Strategische Verortung + +Diese Landing ist **kein Funnel** im klassischen SaaS-Sinn. Sie ist ein **kurzer Vertriebs-Touchpoint**, der drei Dinge leistet: + +1. Den Markencharakter von businessportal24 vermitteln (aktuell, KMU-nah, erwachsen) +2. Vertrauen für die Veröffentlichungs-Entscheidung aufbauen +3. Sauber in den **Hub-Funnel** auf pressekonto.de übergeben, wo Account, Credits, Abrechnung und Tools liegen + +Was hier _nicht_ hingehört: ausführliche Preistabellen, mehrstufige Onboarding-Flows, Account-Verwaltung, Dashboard-Vorschauen. Das ist Hub-Territorium. + +**Tonalität:** wirtschaftsnah, klar, sachlich-lebendig. Nicht Marketing-Sprech. Nicht „SaaS-Begeisterung". Eher Tageszeitungs-Wirtschaftsteil als Stripe-Landingpage. + +**Visuelle Linie:** typografische Dichte statt Whitespace-Verschwendung. Orange/Rot als **Akzent**, nicht als flächiger Gradient. Editorial-Anmutung: schmale Spalten, klare Hierarchie, sachliche Bildwelt (echte Pressefotos / Unternehmensfotos, keine generischen Stock-Illustrationen). + +## 2. Seitenstruktur + +### Above the fold + +**Hero-Block** + +- **H1** (eine der Varianten): + - „Pressemitteilung veröffentlichen. Geprüft. Dauerhaft auffindbar." + - „Die Wirtschaftspresse für den deutschen Mittelstand." + - „Veröffentlichen, wo Mitteilungen nicht verschwinden." +- **Sub** (USP-Kern, max. 2 Sätze): _„businessportal24 ist die Presseplattform für mittelständische Unternehmen, Selbstständige und PR-Agenturen. Jede Veröffentlichung wird auf Qualität geprüft und bleibt dauerhaft im Archiv auffindbar."_ +- **Primary CTA**: „Jetzt veröffentlichen" → führt in den Hub-Funnel (pressekonto.de), Cross-Domain-Auth via Sanctum +- **Secondary CTA**: „So funktioniert's" → Scroll-Anker zum „Ablauf"-Abschnitt + +**Was bewusst fehlt im Hero:** + +- Kein „Jetzt 14 Tage kostenlos testen" +- Kein „Reichweite für Ihre Marke"-Versprechen +- Kein Hero-Bild mit lächelnden Stockfoto-Menschen +- Kein riesiger orangener Gradient-Banner (die SaaS-Optik, die im aktuellen Redesign-Feedback bemängelt wurde) + +### Trust-Block: Was uns ausmacht + +Vier knappe Kacheln, jeweils mit Headline + 1–2 Sätzen. Keine großen Icons (oder nur sehr zurückhaltend, monochrom). Editorial-Look statt Marketing-Look. + +1. **Geprüfte Qualität** _„Jede Mitteilung durchläuft eine Qualitätsprüfung, bevor sie online geht. Keine SEO-Spam-Texte, keine reinen Werbeanzeigen."_ + +2. **Dauerhaft auffindbar** _„Pressemitteilungen bleiben im Archiv – auch nach Jahren. Über 100.000 Mitteilungen aus mehr als einem Jahrzehnt sind weiterhin abrufbar."_ + +3. **Faire Konditionen** _„Transparente Preise, kein Abo-Zwang, keine Vertragsfallen. Sie zahlen, was Sie veröffentlichen."_ + +4. **Korrektur statt Löschung** _„Fehler in einer Mitteilung? Wir korrigieren statt zu löschen – damit Verweise und Verlinkungen bestehen bleiben."_ + + +### Ablauf: So funktioniert's + +Drei Schritte, knapp gehalten. Bewusst entmystifiziert – keine „Magic"-Sprache. + +1. **Konto anlegen** – mit E-Mail-Adresse. Kein Passwort nötig, Login per Magic-Link. +2. **Mitteilung einreichen** – Text, Bild, Ansprechpartner. Eine Qualitätsprüfung läuft automatisch und durch eine kurze redaktionelle Sichtung. +3. **Veröffentlichung** – nach Freigabe online und im Archiv. Bei Bedarf jederzeit korrigierbar. + +Hinweis am Ende des Blocks (klein, sachlich): _„Die Veröffentlichung erfolgt über den zentralen Publisher-Bereich auf pressekonto.de."_ – das setzt die Hub-Architektur transparent. + +### Für wen das richtig ist + +Kurzer Abschnitt mit konkreten Anlässen statt abstrakter Zielgruppen-Beschreibung. Macht die Eignung selbst-evident. + +_„Typische Pressemitteilungen auf businessportal24:_ + +- _Neue Produkte oder Dienstleistungen_ +- _Personalien und Geschäftsleitungs-Wechsel_ +- _Auszeichnungen und Zertifizierungen_ +- _Standort-Eröffnungen, Expansionen, Aufträge_ +- _Veranstaltungs-Ankündigungen_ +- _Studien und Marktanalysen aus Unternehmenshand"_ + +Anschließend ein Satz zur Eingrenzung – das ist wichtig für die Qualitätsschwelle: + +_„Nicht geeignet sind reine Werbeanzeigen, SEO-Linkbuilding-Texte oder Inhalte ohne nachvollziehbaren Pressewert."_ + +### Preise (kurz, mit Verweis) + +Hier **kein** vollständiges Preismodell. Stattdessen: + +- Ein Satz zur Logik: _„Sie kaufen Credits, die für Veröffentlichungen eingesetzt werden. Keine Mindestlaufzeit, keine versteckten Gebühren."_ +- Ein Preis-Anker, damit der Besucher nicht ratlos bleibt: _„Eine Veröffentlichung ab X € – Mengenrabatte verfügbar."_ (Konkreter Wert nachträglich) +- Link zum Hub: _„Vollständige Preisübersicht im Publisher-Bereich →"_ + +### Kurz-FAQ + +Maximal 4–5 Fragen. Direkt, ohne Marketing-Worte. + +- _„Wie schnell wird meine Mitteilung veröffentlicht?"_ – Werktags üblicherweise innerhalb von 24 Stunden nach Einreichung. +- _„Bleibt meine Mitteilung dauerhaft online?"_ – Ja. Mitteilungen werden nicht gelöscht. Korrekturen sind jederzeit möglich. +- _„Kann ich auch auf presseecho.de veröffentlichen?"_ – Ja, über den zentralen Publisher-Bereich. Cross-Publishing ist optional verfügbar. +- _„Brauche ich ein Abo?"_ – Nein. Sie kaufen Credits nach Bedarf, ohne Vertragsbindung. +- _„Was passiert bei einem Fehler in der Mitteilung?"_ – Korrektur statt Löschung: Inhalte werden aktualisiert, die URL bleibt erhalten. + +### Final CTA + +Schlicht. Kein „Letzte Chance"-Pathos. + +- **H2**: „Bereit zu veröffentlichen?" +- **Button**: „Konto anlegen" → Hub +- **Klein darunter**: „Oder zuerst Beispiele ansehen →" (Link zur Startseite des Portals) + +### Footer + +Standard, aber mit zwei brand-spezifischen Elementen: + +- Hinweis auf den Betreiber: _„businessportal24 ist ein Service der Pressekonto-Gruppe."_ (oder ähnlich – siehe offener Punkt aus Update 4) +- Cross-Link zu presseecho.de (dezent, eine Zeile): _„Für fachlich-spezifische Themen: presseecho.de"_ + +## 3. Visuelle Leitlinien + +### Was diese Seite tun soll + +- **Editorial wirken**, nicht promotional. Vorbild: Wirtschaftsteil einer überregionalen Zeitung, nicht Stripe/Linear/Notion. +- **Typografisch arbeiten**: klare Hierarchien, ausreichend Dichte, lesbare Spaltenbreiten (~65–75 Zeichen). +- **Farben sparsam**: Orange/Rot als Akzent (Links, primärer CTA, dünne Trennlinien, gelegentliche Rubriken-Marker). Keine flächigen Gradienten, keine farbigen Cards als Hintergrund. +- **Sachlichkeit ausstrahlen**: Pressemitteilungen sind ein seriöses Produkt. Optik muss das transportieren. + +### Was diese Seite _nicht_ tun darf + +- **Keine** Hero-Gradienten in Orange (siehe Feedback zum ersten Redesign-Versuch) +- **Keine** generischen Briefkasten-/Megafon-Icons +- **Keine** „lifestyle"-Bildwelt mit lächelnden Menschen am Laptop +- **Kein** SaaS-Vokabular („Plattform", „Solution", „Empower your communications") +- **Keine** Pseudo-Trust-Elemente wie „4.9 Sterne", „10.000 zufriedene Kunden" – wenn es echte Zahlen gibt, dann nüchtern darstellen + +### Layout-Empfehlung + +- **Maximalbreite** ~1100–1200px, zentriert, mit ausreichend Seitenrand +- **Hero** kompakt, nicht ganzseitig hoch – Pressekontext heißt: Information schnell sichtbar +- **Sektions-Trenner** über Typografie und schmale Linien, nicht über farbige Background-Bänder +- **Mobile**: einspaltig, gleiche Reihenfolge, Hero noch kompakter + +## 4. Technische Hinweise + +- **Routing**: `businessportal24.com/veroeffentlichen` rendert eine eigene Brand-Landing-Route, die das geteilte Backend nutzt aber brand-spezifische Templates lädt +- **CTA-Übergabe**: Klick auf „Jetzt veröffentlichen" / „Konto anlegen" leitet zum Hub (`pressekonto.de/publisher/start?brand=businessportal24`), damit der Hub weiß, welche Brand der Einstiegspunkt war (für Default-Brand-Zuordnung und ggf. Tracking) +- **Cross-Domain-Auth**: Wie in Update 3 festgelegt via Sanctum. Wenn der Besucher bereits eingeloggt ist, springt er direkt ins Dashboard, sonst in den Onboarding-Flow +- **Tracking**: Brand-Landing trackt eigenen Funnel-Eintritt, der Hub übernimmt ab dort. So bleibt nachvollziehbar, welche Brand-Landing welche Conversion-Rate hat + +## 5. Copy-Hinweise zur weiteren Verwendung + +Die Copy-Vorschläge in Abschnitt 2 sind **Ausgangsmaterial**, nicht final. Sie verkörpern aber die Tonalität, die in Update 4 festgelegt wurde: + +- aktiv, klar, wirtschaftsnah, zugänglich +- keine Reichweiten- oder Lead-Versprechen +- konkrete Beispiele statt abstrakte Zielgruppen-Floskeln +- ehrliche Eingrenzung (das „Nicht geeignet"-Statement) + +Beim Schreiben weiterer Texte gilt: Im Zweifel **eine Aussage weniger** statt eine Aussage mehr. Pressecharakter heißt zurückhaltend, nicht prahlend. + +## 6. Offene Punkte + +- Konkrete Preis-Anker (X € pro Veröffentlichung) – sobald die Preislogik aus Update 3 finalisiert ist +- Beispiel-Mitteilungen zum Anzeigen auf der Landing – sinnvoll oder unnötig? (Die Startseite tut das ohnehin) +- Soll der Footer-Hinweis auf presseecho.de prominent oder eher unauffällig sein? Hängt an der Grundsatzentscheidung aus Update 4 (sichtbare Markenfamilie vs. strikt getrennt) +- A/B-Test-Strategie für die drei H1-Varianten – sinnvoll erst, wenn Mindest-Traffic erreicht ist + +--- + +_Dieses Konzept ist eine konkrete Implementierungs-Vorlage. Es schöpft die Positionierung aus Update 4 in seitenstrukturelle Form und benennt explizit, was diese Landing nicht leisten soll – um die Anti-Zombie-Regel auf operativer Ebene umzusetzen._ \ No newline at end of file diff --git a/docs/user-admin/Admin-User.md b/docs/user-admin/Admin-User.md new file mode 100644 index 0000000..9598290 --- /dev/null +++ b/docs/user-admin/Admin-User.md @@ -0,0 +1,428 @@ +# User Backend und Admin Backend + +> **Stand der Doku**: 11.06.2026 — Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Klassifikation + Content-Score) sind abgeschlossen. +> Aktueller Code-vs-Konzept-Abgleich: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](../STATUS-ABGLEICH-USER-PANEL.md). +> Naechster Block (Zahlung/Tarife, Veroeffentlichungs-Flow): [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md). + +Dieses Konzept beschreibt das gemeinsame Backend aus zwei Perspektiven: + +- **User Backend**: Self-Service-Bereich für Kunden/User zur Pflege eigener Firmen, Kontakte, Pressemitteilungen, Rechnungen, API-Tokens und Einstellungen. „Pressemappe" bleibt als öffentlicher/PR-bezogener Kontext erhalten, nicht als Hauptnavigation im User Backend. +- **Admin Backend**: Verwaltungsbereich für interne Admins/Editoren. Die bestehende Admin-Oberfläche bleibt in Phase 1 unverändert. + +Beide Bereiche laufen technisch im gleichen Backend. Die sichtbaren Menüs, Aktionen und Daten werden über Rollen, Policies und Berechtigungen getrennt. Admins können sich über Impersonation als User einloggen, um dessen User-Backend-Sicht nachzuvollziehen und Inhalte zu prüfen oder zu korrigieren. + +> Hinweis Routen-Namen: In der UI heißen die Firmen ueberall „Firmen". Aus +> historischen Gruenden tragen die zugehoerigen Routen weiterhin den +> Praefix `me.press-kits.*` (z. B. `me.press-kits.show`). Das ist nur +> ein Routen-Name, fachlich sind es Firmen. Eine Umbenennung der Routen +> ist nicht geplant, weil sie nur intern relevant ist. + +Vorab siehe hierzu folgende Mechanik für Untermenüs https://pressekonto.test/settings/profile + +# Aktualisierte Navigation + +## Phasen-Farbcode + +Für die weitere Planung werden Features farblich/phasenbasiert getrennt: + +- **Grün / Phase 1**: auf dem bestehenden Datenmodell kurzfristig umsetzbar. +- **Gelb / Phase 2**: braucht kleinere neue Tabellen, Policies oder Services. +- **Rot / Später**: strategische Produkt-/Monetarisierungsthemen mit größerem Datenmodell- oder Rechtsaufwand. + +## Umsetzungsstand Phase 1 + +Bereits umgesetzt: + +- Firmen-Kontext-Switcher im User Backend mit „Alle Firmen" und Einzelfirma, platziert rechts in der Topbar. +- User-Backend-Navigation gegliedert in „Mein Bereich", „Finanzen" und „Konto". +- „Buchungen & Add-ons" ist als vorbereiteter Bereich eingebunden; Statistiken, Credits/Tarif, Zahlungsarten und Benachrichtigungen bleiben markierte spätere Punkte. +- Dashboard und PM-Liste reagieren auf den aktiven Firmen-Kontext. +- Firmen-Liste und Firmen-Detail auf Basis der bestehenden `companies`, `contacts` und `press_releases`. +- Zugriff auf Firmen ist auf eigene bzw. zugeordnete Firmen begrenzt. +- Öffnen einer Firma setzt die aktive Firma für den weiteren User-Backend-Kontext. +- Kontaktverwaltung innerhalb der Firma für Owner und Verantwortliche; Mitglieder bleiben lesend. +- Neue Pressemitteilungen übernehmen die aktive Firma als Vorauswahl. +- PM-Detail zeigt zugeordnete Pressekontakte sowie Status-/Verlaufsdaten. +- Rechnungen sind im User Backend in einer eigenen Finanznavigation eingeordnet; Legacy bleibt als Archivhinweis im Inhalt sichtbar. +- Rechnungen zeigen einen Hinweisblock; Rechnungsadresse wird im Profil als eigener Bereich gepflegt. +- Firmen-Stammdaten werden sichtbar in der Firma gepflegt; die Profilseite verweist nur noch auf die jeweilige Firma. +- Dashboard zeigt erste Datenqualitäts-Hinweise aus bestehenden Tabellen: Profil, Rechnungsadresse, Pressekontakte und Legacy-PMs ohne Firma. + +Noch offen in Phase 1: + +- Keine offenen Punkte aus der ersten grünen User-Backend-Ausbaustufe. + +## Umsetzungsstand Phase 7 (PM-Form-Refactor) + +Phase 7 ist mit Stand 21.05.2026 abgeschlossen. Sie hat das Form-Erlebnis für Pressemitteilungen vereinheitlicht und das Datenmodell um mehrere Felder erweitert. Details: `dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md`. + +Zusammenfassung: + +- **Neue PM-Felder**: `subtitle`, `scheduled_at`, `embargo_at`, `boilerplate_override`, `no_export`. Migrationen liegen in `database/migrations/2026_05_20_*`. +- **HTML-Sanitizer**: Inhalt wird serverseitig durch `mews/purifier` gereinigt (`App\Services\PressRelease\PressReleaseHtmlSanitizer`). +- **Sidebar-Aufbau** in Customer- und Admin-Forms identisch (Status & Absenden, Kategorie, Portal-Pill, Pressekontakt, Themen-Tags, Veröffentlichung, Weitere Felder, Phase-2-Footer). +- **Pressekontakt-Pflichtfeld** aufgehoben — Auswahl bleibt empfohlen, ist aber technisch nullable. Eine Warn-Box im Sidebar-Card (Phase 8) macht das transparent. +- **Anhänge-/Downloads-UI deaktiviert** wegen ausstehendem Security-Review. Tabelle `press_release_attachments` und Manager-Komponente bleiben erhalten. +- **Background-Job** `php artisan press-releases:publish-scheduled` veröffentlicht geplante PMs (alle 5 Minuten via Scheduler). +- **UX**: `Flux::toast()` für alle Erfolg/Fehler-Meldungen, Smooth-Scroll zum ersten Validation-Fehler nach Save, `presubmitChecks` als kompakte Pflichtfeld-Übersicht im Sidebar. + +## Phase 8 (abgeschlossen 29.05.2026) + +Plan-Doku: `docs/PHASE-8-USER-PANEL-PLAN.md`. Schwerpunkte: + +1. ✅ Show-Page-Lücken aus Phase 7 schließen (Subtitle, Scheduling, Embargo, Boilerplate-Override) — Customer + Admin (8A). +2. ✅ Listen-Indikatoren für geplante Veröffentlichung und Embargo (8B). +3. ✅ Pressekontakt-Warn-Box in der Form-Sidebar, wenn kein Kontakt gewählt (8C). +4. ✅ Firmen-Liste auf Mockup-Niveau (Counter-Strip, Saved-Views, Filter-Chips, Card/List-Toggle, Rollen-Legende) (8E). +5. ✅ SVG-Platzhalter-Set für PM-Titelbilder + Auswahl-Modal + Cover-Resolver (8F/8G). +6. ✅ Image-Manager mit Lizenz-Pflichtfeldern (Urheber/Lizenztyp/Lizenz-URL/Rechte-Bestätigung) (8H). +7. ✅ Veröffentlichungs-Modal mit rechtlichen Hinweisen und Kontingent-Stub (8I/8J; das echte Tarif-System kommt später). + +## KI-Prüfung & Veröffentlichung (abgeschlossen 11.06.2026) + +Detail-Doku: `Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`. Kurzfassung: + +- Jede Einreichung (Customer-Form **und** API) läuft durch denselben Funnel: Blacklist-Hard-Filter → asynchrone KI-Klassifikation (Rot/Gelb/Grün, OpenAI mit deterministischem Fallback) → Status-Routing. +- Rot → abgelehnt + Begründung per Mail; Gelb → manuelle Admin-Review-Queue; Grün → Auto-Publish (sofort oder zum geplanten Termin). +- Jede KI-Entscheidung wird in `ki_audits` protokolliert; Admin sieht Badge, Begründung und kann nach Klassifikation filtern. On-Demand-Prüfung über den „Prüfung"-Button im Admin-Editor. +- Zusätzlich Content-Score 0–100 → Stufe Standard/Geprüft/Hochwertig mit Editor-Panel und Badges. +- Parallel umgesetzt (10./11.06.): ein Titelbild pro PM (Cover 1280×580), erweitertes Lizenz-/Rechteformular, Termine in Europe/Berlin (Speicherung UTC), Embargo aus der Form-UI entfernt — siehe `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`. + +## Topbar + +Oben rechts über dem Content: + +**Firmen-Kontext-Switcher** Dropdown „Aktive Firma: [bma.cc ▼]" mit drei Optionen. Vorteil: Die Sidebar bleibt schlank und einklappbar, ohne den aktiven Firmenkontext zu verlieren. + +- Einzelne Firma wählen → filtert Dashboard, PMs, Kontakte, Statistiken auf diese Firma +- „Alle Firmen" → aggregierte Sicht +- „Firma anlegen" am Ende der Liste + +Da die User-Firmen-Beziehung n:m mit Rollen ist (`member`, `responsible`, `owner`), zeige ich pro Eintrag ein dezentes Rollen-Icon. Das hilft dem User zu verstehen, wo er was darf. + +Wichtig: Da das Portal über die Firma abgeleitet wird, ist der Switcher implizit auch der Portal-Switcher. Sauber gelöst, ohne zweites Konzept. + +--- + +## Hauptnavigation (überarbeitet) + +### Gruppe „Mein Bereich" + +**1. Dashboard** Zusätzlich zu vorher genannten Elementen jetzt mit **Datenqualitäts-Hinweisen**, weil das Datenmodell zeigt, dass viele Pflichtfelder optional sind: + +- „Rechnungsadresse fehlt – Rechnungen können nicht erstellt werden" +- „3 Pressemitteilungen ohne Firmenzuordnung (Legacy)" +- „Profil unvollständig – ergänzen für Verifizierung" + +Diese Hinweise sind dismissible und verschwinden bei Erledigung. Das senkt deinen Support-Aufwand erheblich, weil User ihre eigenen Datenlücken sehen. + +Phase: **Grün**, wenn die Hinweise auf vorhandene Tabellen beschränkt bleiben (`profile`, `billing_addresses`, `company_user`, `companies`, `press_releases`). + +**2. Pressemitteilungen** Erweiterungen aus dem Datenmodell: + +- **PM-Detailansicht** zeigt einen „Status & Verlauf"-Block (aus `press_release_status_logs` mit Status-Wechseln, Grund, Quelle, Zeitpunkt) — als Card auf der Show-Page, nicht als eigener Tab. +- Filter „PMs ohne Firma" (für Legacy-Migration) +- Filter „PMs mit Portalabweichung" (falls du das den Usern zeigen willst – ich würde es eher in den Admin-Bereich legen) +- **Filter-Presets** (aus `user_filter_presets`): User kann seine eigenen Filter speichern, „Meine Entwürfe der letzten 30 Tage" etc. +- In der PM-Detail: zugeordnete `press_release_contact`-Kontakte als eigene Sektion + +Stand 21.05.2026: + +- **PM-Felder** umfassen jetzt zusaetzlich `subtitle`, `scheduled_at`, `embargo_at`, `boilerplate_override`, `no_export` (Phase 7). +- **Editor** ist `flux:editor` mit Absaetzen, fett, kursiv, Listen, Zitat, Links und Headings. Der Inhalt wird beim Speichern serverseitig durch HTMLPurifier (`mews/purifier`, gekapselt in `PressReleaseHtmlSanitizer`) bereinigt. +- **Pressekontakt-Auswahl** ist Single-Select aus den Kontakten der Firma, **optional** und mit Warn-Box, wenn leer. Das Pivot `press_release_contact` bleibt n:m, fuer den Customer-Flow wird aber maximal ein Kontakt pro PM gespeichert. +- **Anhaenge** sind im UI deaktiviert (Security-Review). Tabelle `press_release_attachments` und Service `PressReleaseAttachmentStorage` bleiben erhalten. +- **Filter-Presets** sind weiterhin **Gelb** (Tabelle existiert, UI noch nicht aktiv). + +Phase: **Gruen** fuer Liste, Detail, Statusverlauf, Firmenpflicht, Untertitel, Scheduling und Boilerplate-Override. **Umgesetzt (11.06.)**: KI-Pruefung bei Einreichung (Klassifikation + Content-Score, siehe `Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`); Embargo wurde aus der Form-UI entfernt. **Gelb** fuer Filter-Presets. **Rot/Spaeter** fuer Vorab-KI-Pruefung ohne Einreichung, Notice-and-Action und Korrektur-/Update-Hinweis-System (siehe `Presseportal – Konzept für Relaunch.md`). + +**3. Firmen** Klar strukturierter Detailbereich pro Firma, weil hier am meisten dranhängt: + +- Tab **Stammdaten** (Firma, Logo, Branche, Footer-Code-Flag) +- Tab **Pressekontakte** – wichtig: Kontakte hängen an der Firma, nicht direkt am User. Hier verwaltet der User die Liste der hinterlegten Pressekontakte. Direkte `contact_user`-Pivots würde ich für den User unsichtbar lassen, da sie eher technisches Artefakt sind. +- Tab **Pressemitteilungen** der Firma +- Tab **Statistik** der Firma +- Tab **Abrechnung** – falls Zahlungsoptionen über `user_payment_option_company` an Firmen gehängt sind, hier sichtbar +- Eigentümer-Anzeige: konsolidiert aus `owner_user_id` UND `company_user.role = owner`. Falls beides existiert und divergiert → Datenqualitätshinweis (eher Admin-Thema, aber User sollte zumindest wissen, wer Owner ist) + +Phase: **Grün** für Stammdaten, Kontakte, PMs und Eigentümeranzeige auf bestehendem Modell. **Gelb** für Team-Einladungen, Owner-Übertragung und firmenscharfe Abrechnung. + +**4. Medien** Wie zuvor (Eigene / Stock / KI), aber Bilder kommen aus `press_release_images`. Konzeptionell sollte die Bibliothek über alle PMs/Firmen aggregieren. Wenn das Datenmodell aktuell nur 1:n PM→Bild ist, müsste das später auf eine eigenständige `media_library` mit polymorpher Verwendung in PMs umgebaut werden. Aber das ist Phase 2 – fürs Konzept reicht erstmal die Aggregations-Sicht. + +Phase: **Gelb** für eine aggregierte Ansicht vorhandener `press_release_images`; **Rot/Später** für eine echte wiederverwendbare Medienbibliothek. + +**5. Statistiken** Reichweite/Performance aggregiert oder nach Switcher-Auswahl gefiltert. + +Phase: **Grün**, soweit nur vorhandene `hits`, PM-Status und Zeiträume genutzt werden. Erweiterte Quellen, Verweildauer oder Demografie sind **Rot/Später**. + +--- + +### Gruppe „Buchen & Bezahlen" + +**6. Buchungen & Add-ons** _(neu als eigener Punkt)_ Zentraler Marktplatz für alles Verbrauchsbasierte: + +- Highlights (Kategorie / Startseite / Top-Slot) +- KI-Services (Lektorat, Quality-Check, Übersetzung, Bildgenerierung) +- Premium-Stock +- Newsletter-Erwähnung +- Verteiler-Versand +- Verifiziertes Firmenprofil +- Custom Domain + +Mit Tabs: + +- **Verfügbar** (Marktplatz, alle buchbaren Services) +- **Aktive Buchungen** (was läuft gerade, wann endet es) +- **Verlauf** (was wurde wann gebucht) + +Zusätzlich: Aus dem PM-Editor heraus immer noch der direkte „Highlight buchen"-Button als kontextueller Einstieg. Beide Wege koexistieren. + +**7. Credits & Tarif** Wie zuvor, zwei Tabs. + +**8. Rechnungen** Wie zuvor: aktuelle + Legacy als Archiv-Tab. + +--- + +### Gruppe „Konto" + +**9. Einstellungen** Tabs strukturiert auf Basis des Datenmodells: + +- **Profil** (`profiles`-Daten: Anrede, Titel, Adresse, Geburtsdatum, Backlink, Statistik-/Footer-Code-Flags) +- **Rechnungsadresse** (`billing_addresses` – getrennt von Profil, weil eigene Tabelle und oft abweichend) +- **Sicherheit** – hier zeigt das Datenmodell Möglichkeiten, die du dem User geben solltest: + - Passwort & 2FA + - Aktive Sessions (`sessions`) + - **Magic-Link-Verlauf** (`magic_links` – Zweck, Zeitpunkt, IP) – wertvoll für Transparenz und Sicherheit + - Login-Verlauf +- **Benachrichtigungen** – verbunden mit `newsletter_subscriptions`: User sieht hier seine Newsletter-Abos pro Portal, kann steuern +- **Zahlungsmethoden** (`user_payment_options` – inkl. Verknüpfung zu Firmen, falls vorhanden) +- **Team** (für Agency-Tarif: `company_user`-Pivots verwalten, Rollen vergeben) +- **API & Integrationen**: + - Tokens (`personal_access_tokens` mit Berechtigungen, letzter Zugriff) + - **API-Nutzungs-Log** (`api_usage_logs` – Methode, Pfad, Status, Dauer) als eigener Sub-Tab. Das ist Gold für API-User und entlastet deinen Support enorm. + - Webhooks + +Phase: **Grün** für Profil, Rechnungsadresse, Sicherheit, Newsletter und API-Tokens. **Gelb** für Magic-Link-/Token-Request-Historie und API-Nutzungs-Log. **Rot/Später** für Webhooks. + +Hinweis unten bei dem Namen ist ein Menü, wo auch noch einmal Settings verknüpft sind https://pressekonto.test/settings/profile +--- + +### Gruppe „Hilfe" + +**10. Hilfe & Support** wie zuvor. + +--- + +# Was sich konkret durch das Datenmodell geändert hat + +|Feature|Wo verankert|Datenquelle| +|---|---|---| +|Datenqualitäts-Hinweise auf Dashboard|Dashboard|`profile`, `billing_address`, PM-`company_id`-Null-Checks| +|PM-Statusverlauf|PM-Detail, Tab „Verlauf"|`press_release_status_logs`| +|Filter-Presets|PM-Liste|`user_filter_presets`| +|Magic-Link-Historie|Einstellungen → Sicherheit|`magic_links`| +|API-Nutzungs-Log|Einstellungen → API|`api_usage_logs`| +|Newsletter-Abos|Einstellungen → Benachrichtigungen|`newsletter_subscriptions`| +|Pressekontakte je Firma|Firma → Bereich „Pressekontakte"|`contacts` via `company_id`| +|Eigentümer-Anzeige|Firma → Stammdaten|`owner_user_id` + `company_user.role`| +|Zahlungsoptionen pro Firma|Firma → Bereich „Abrechnung"|`user_payment_option_company`| + +--- + +# Zwei strategische Punkte aus deinem Datenmodell, die ich aufwerfen würde + +**1. Direkte `contact_user`-Pivots im User-UI verstecken** Das Datenmodell erlaubt, Kontakte direkt an User zu hängen (zusätzlich zur Pflicht-Zuordnung an eine Firma). Für den User-UI würde ich das **nicht** sichtbar machen – das verwirrt. Kontakte werden über die Firma verwaltet. Punkt. Die direkte Pivot-Zuordnung kann technisch bleiben (z. B. „User darf diesen Kontakt sehen" über alle Firmen hinweg), aber UI-seitig bleibt es bei „Firma → Kontakte". + +**2. PMs ohne Firma** Das Datenmodell erlaubt `company_id = null`. Im User-UI würde ich diese Fälle: + +- Auf dem Dashboard als Hinweis listen („3 PMs ohne Firmenzuordnung – jetzt zuordnen") +- In der PM-Liste als eigenen Filter +- Im PM-Editor als Pflichtfeld erzwingen (auch wenn DB es zulässt) + +So drehst du die Datenqualität schrittweise sauber, ohne harte Migration. + +--- + +# Firmen-Detail (User-Sicht) + +> **IST-Stand 21.05.2026**: Die Firmen-Detailseite ist umgesetzt als +> **eine lange Seite mit Quick-Nav-Ankern** statt mit echten Tab-Wechseln. +> Die im folgenden beschriebene Tab-Struktur ist konzeptuell gleichwertig +> und kann bei Bedarf in eine echte Tab-Komponente umgezogen werden, +> ohne den Funktionsumfang zu aendern. +> +> **Route**: `/admin/me/press-kits/{company}` mit dem Routen-Namen +> `me.press-kits.show`. In der UI heisst der Bereich „Firmen". + +## Aufruf + +Drei Wege führen hierher: + +- Klick auf einen Eintrag in der Firmen-Liste +- Klick auf den Firmennamen im Firmen-Kontext-Switcher (→ aktive Firma + Sprung in Detail) +- Tiefenlinks aus PM-Detail („zur Firma"), Statistik („Firma im Detail") + +URL-Struktur: `/firmen/{id}` im User Backend (konzeptueller Zielzustand). IST: `/admin/me/press-kits/{id}` (siehe Routen-Name oben). Öffentliche Pressemappe bleibt ein separater PR-Kontext. + +--- + +## Header (über allen Tabs sichtbar) + +Kompakte Header-Karte mit: + +- **Logo** (links, klickbar zum Ändern wenn berechtigt) +- **Firmenname** + dezenter `slug`-Hinweis +- **Status-Badges nebeneinander**: + - Portal (welches der beiden Portale) + - Verifizierungs-Status (Häkchen oder „Nicht verifiziert") + - Aktiv/Inaktiv + - Deine Rolle: Owner / Verantwortlich / Mitglied +- **Aktions-Menü** rechts: + - Primär: „Neue Pressemitteilung" (führt direkt in Editor mit dieser Firma vorausgewählt) + - Sekundär (Dropdown): „Verifizierung beantragen", „Custom Domain einrichten", „Als inaktiv markieren", „Firma übertragen" + +Der Header bleibt beim Tab-Wechsel stehen, sodass Kontext (welche Firma, welche Rolle) nie verloren geht. + +--- + +## Tab-Struktur (6 Tabs) + +### Tab 1: Übersicht (Default) + +Eine Mini-Dashboard-Sicht für genau diese Firma. Gibt dem User sofort das Gefühl „hier passiert was" beim Reinklicken. + +Inhalte: + +- **KPI-Reihe**: Anzahl PMs gesamt, Veröffentlicht in den letzten 30 Tagen, Aktive Highlights, Reichweite (30 Tage) +- **Letzte 5 Pressemitteilungen** dieser Firma mit Status und Datum +- **Pressekontakte-Block**: kompakte Liste, „X Kontakte hinterlegt", Sprung in Tab 3 +- **Datenqualitäts-Hinweise** firmenspezifisch: + - „Logo fehlt" + - „Keine Pressekontakte hinterlegt – Änderungs-Workflow nicht möglich" + - „Owner-Konflikt: `owner_user_id` und `company_user.role=owner` divergieren" (eher Admin-Hinweis, aber wenn es User betrifft, transparent zeigen) + - „Branche nicht gesetzt – beeinträchtigt Auffindbarkeit" +- **Quick Actions**: „Neue PM", „Pressekontakt hinzufügen", „Highlight buchen" + +### Tab 2: Stammdaten + +Bearbeitbare Firmendaten. Felder gemäß `companies`-Tabelle: + +- Firmenname * +- Logo (Upload, mit Preview) +- Kurzbeschreibung (1–2 Sätze für Listing-Ansichten) +- Lange Beschreibung (für die Firmenseite) +- Branche/Kategorie * +- Adresse: Straße, PLZ, Ort, Land +- Website-URL +- Footer-Code-Flag (mit kurzer Erklärung was es bewirkt) +- Aktivstatus (Toggle, mit Warnhinweis was passiert) + +**Eigentümer-Block** (read-only für Nicht-Owner): + +- Anzeige des konsolidierten Eigentümers +- Bei Divergenz zwischen `owner_user_id` und `company_user.role=owner`: gelber Warnhinweis mit „An Support melden"-Link + +**Portal-Anzeige**: + +- Read-only mit Tooltip: „Das Portal wird durch die Firma festgelegt und kann nicht im Self-Service geändert werden. Bei Bedarf bitte Support kontaktieren." + +**Verifizierung**: + +- Status anzeigen +- Wenn nicht verifiziert: CTA „Verifizierung beantragen" → führt zu Buchungen & Add-ons mit vorausgewähltem Service + +### Tab 3: Pressekontakte + +Verwaltung der `contacts` dieser Firma. Direkte `contact_user`-Pivots werden hier nicht angezeigt – Kontakte gehören zur Firma, Punkt. + +Liste mit: + +- Name, Position, E-Mail, Telefon +- Status-Badge: „Magic-Link aktiv" / „Magic-Link inaktiv" +- Anzahl PMs, in denen dieser Kontakt referenziert ist (aus `press_release_contact`) +- Aktionen: Bearbeiten / Löschen / Test-Magic-Link senden + +Oben: „+ Neuer Pressekontakt" mit Formular (Name, Position, E-Mail, Telefon, Magic-Link-Berechtigung ja/nein). + +**Wichtiger Erklärungsblock** über der Liste (einmalig dismissible): + +> Pressekontakte sind die offiziellen Ansprechpartner zu dieser Firma. Sie können sich per Magic-Link einloggen, um Pressemitteilungen zu korrigieren, zu aktualisieren oder DSGVO-Anfragen zu stellen. Hinterlegen Sie alle relevanten Kontakte, um den autorisierten Änderungs-Workflow zu ermöglichen. + +Beim Löschen eines Kontakts: Warnung, falls dieser noch in PMs referenziert ist („In 12 PMs hinterlegt – diese verlieren den Kontakt"). + +### Tab 4: Pressemitteilungen + +Gefilterte PM-Liste auf `company_id` dieser Firma. + +- Standard-Filter (Alle / Veröffentlicht / In Prüfung / Entwürfe / Depubliziert / Korrekturen) +- Volltextsuche +- „+ Neue Pressemitteilung" mit dieser Firma vorausgewählt +- Pro Eintrag: Titel, Status, Datum, zugeordnete Pressekontakte, Reichweite, Aktionen + +Bulk-Aktionen für Power-User: Mehrere PMs auswählen → „Pressekontakte bulk zuordnen", „Exportieren als PDF". + +### Tab 5: Statistik + +Reichweite und Performance dieser Firma: + +- Views, Klicks, Verweildauer im Zeitverlauf (30/90/365 Tage) +- Top-PMs nach Reichweite +- Verteilung nach Quelle (organisch, Newsletter, Distribution-Partner) +- Kategorien-Heatmap (welche Themen performen) +- Aktive Highlights & Buchungen, die dieser Firma zugeordnet sind +- Im Pro-Tarif zusätzlich: Demografie, Geräte, Suchbegriffe + +Export-Button (CSV/PDF) – sinnvoll für Reportings, die User intern an Marketing/Geschäftsführung weiterleiten. + +### Tab 6: Abrechnung + +Hier wird's etwas heikel, weil Abrechnung hauptsächlich am User hängt, aber `user_payment_option_company` einen firmenscharfen Bezug erlaubt. + +Inhalte: + +- **Zahlungsmethoden für diese Firma** – Liste der `user_payment_options`, die per Pivot mit dieser Firma verknüpft sind +- „Zahlungsmethode dieser Firma zuordnen" (aus den vorhandenen User-Zahlungsmethoden auswählen) +- **Rechnungen mit Firmenbezug** – PMs/Buchungen, die diese Firma betreffen, mit den entsprechenden Rechnungen +- **Klarer Erklärtext oben**: + +> Rechnungen werden grundsätzlich auf Ihren User-Account ausgestellt. Hier sehen Sie Zahlungsmethoden und Buchungen, die speziell dieser Firma zugeordnet sind. Eine vollständige Übersicht aller Rechnungen finden Sie unter „Rechnungen". + +Dieser Tab ist nur für Owner sichtbar – Member und Verantwortliche haben hier nichts verloren. + +--- + +## Rollen-Logik (aus `company_user.role`) + +Klare Sichtbarkeits- und Bearbeitungsregeln: + +**Owner**: Alle Tabs, alle Aktionen. Kann Firma deaktivieren, übertragen, Pressekontakte verwalten, Stammdaten ändern, Abrechnung sehen. + +**Verantwortlich**: Übersicht, Stammdaten (read-only), Pressekontakte (verwalten), PMs (verwalten + erstellen), Statistik. Kein Tab Abrechnung. Stammdaten-Änderungen mit Hinweis „Nur Owner kann ändern". + +**Mitglied**: Übersicht, Stammdaten (read-only), Pressekontakte (read-only), PMs (eigene erstellen, nur eigene bearbeiten), Statistik. Kein Tab Abrechnung. + +Im Header die Rolle als Badge zeigen, damit der User immer weiß, was er darf, ohne dass er es durch Klicken herausfindet. + +--- + +## Verknüpfungen zu anderen Bereichen + +- **Switcher** (Topbar rechts): Auswahl einer Firma scrollt globale Filter auf diese Firma, der direkte Sprung ins Detail bleibt aber ein expliziter Klick +- **PM-Editor**: PMs werden mit `company_id` erstellt, das Feld ist Pflicht (auch wenn DB nullable). Aus dem Firmen-Detail ist es vorausgewählt. +- **Buchungen & Add-ons**: Highlights, KI-Services, Verifizierung etc. werden in der Buchungs-Sektion abgewickelt, aber aus dem Firmen-Detail kontextuell verlinkt +- **Statistiken (Hauptpunkt)**: Aggregierte Sicht über alle Firmen vs. firmenspezifische Sicht hier im Tab. Beide Wege koexistieren. +- **Einstellungen → Team**: Beim Agency-Tarif können User andere User zur Firma einladen (`company_user`-Pivot mit Rolle setzen). Verlinkung von hier aus sinnvoll. + +--- + +## Offene Designentscheidungen + +**1. Firmenwechsel-Bestätigung** Wenn ein User im Firmen-Detail arbeitet und über den Switcher die Firma wechselt – sofort wechseln oder Warnung „ungespeicherte Änderungen"? Ich würde Standard-Browser-Verhalten beibehalten (`beforeunload` bei dirty forms), kein eigener Dialog. + +**2. Firma deaktivieren vs. löschen** Im Datenmodell ist Aktiv/Inaktiv vorhanden. Echtes Löschen ist heikel wegen verknüpfter PMs, Rechnungen, Kontakte. Ich würde dem User **nur** Deaktivieren anbieten – echtes Löschen läuft über Support-Anfrage. Senkt deine Risiken bei DSGVO-Konflikten. + +**3. Owner-Übertragung** „Firma übertragen" ist ein sensibler Vorgang. Ich würde einen eigenen Wizard mit E-Mail-Bestätigung beim neuen Owner verlangen (ähnlich GitHub-Repo-Transfer). Macht den Punkt komplexer, aber sauber. + +**4. Pressekontakt-Zuordnung beim PM-Erstellen** Beim Anlegen einer neuen PM: Sollen alle Pressekontakte der Firma automatisch zugeordnet werden, oder muss der User explizit auswählen? Ich tendiere zu „alle vorausgewählt, abwählbar" – gibt dem User eine Voreinstellung, die in 90 % der Fälle stimmt. + +--- diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md new file mode 100644 index 0000000..ae8bbfd --- /dev/null +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -0,0 +1,215 @@ +# Billing & Rechnungskreise (hybrides Modell) + +Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie +Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows, Plan-Kontingent +(Phase 9E) und Tarif-Seite/Checkout-UI (Phase 9F) umgesetzt. + +Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem: +Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration. + +Verwandte Dokumente: + +- [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) — verbindliche Launch-Entscheidungen (Tarife, Kontingente, Flow, Netto-Preise). +- [`docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`](../PHASE-9-FLOW-UND-TARIFE-PLAN.md) — Umsetzungsplan mit Päckchen-Status. +- `dev/migration 2026/05-DATABASE-MERGE.md` §5.5/§5.6 — Rechnungsarchiv (D-12) und Grandfathering (D-13). + +--- + +## 1. Die drei Rechnungswelten + +| Welt | Präfix | Tabelle | Inhalt | +|---|---|---|---| +| **Stripe-Shop** | `STR-` | `invoices` | Alle **neuen** Abschlüsse (Abos, Einzel-PM, Credits). Abwicklung komplett über Stripe; Rechnungen werden per Webhook in `invoices` gespiegelt und erhalten eine fortlaufende STR-Nummer. *(Spiegelung: Phase 9E)* | +| **Manuell/Legacy** | `MAN-` | `invoices` | Laufende, noch aktive Alt-Zahlungsvereinbarungen ab Relaunch. Fälligkeit wird täglich geprüft, Rechnung wie im Altsystem ausgestellt. | +| **Alt-Archiv** | — | `legacy_invoices` | Read-only Archiv aller importierten Legacy-Rechnungen (D-12). Wird nie verändert; PDFs werden on-demand aus den Archivdaten erzeugt. | + +**Rechnungsnummern**: `InvoiceNumberGenerator` vergibt atomar (Row-Lock auf +`invoice_number_sequences`) fortlaufende, lückenlose Nummern pro Kreis: +`STR-00001`, `MAN-00001`, … (Padding: `billing.invoice_number_padding`). + +--- + +## 2. Tarif-Datenmodell + +| Tabelle | Zweck | +|---|---| +| `plans` | Tarif-Katalog (Starter/Business/Pro/Agency): Monats-/Jahrespreis **netto**, PM-Kontingent/Monat, Tageslimit, Stripe-Produkt-/Preis-IDs. Seeder: `PlanSeeder` (idempotent). | +| `subscriptions`, `subscription_items` | Laravel-Cashier-Tabellen — Zustand der Stripe-Abos. `User` ist `Billable`. | +| `single_purchases` | Einmalkäufe: Einzel-PM (19 €), Extra-PM, Boost, Veröffentlichungsnachweis-PDF. Status: pending → paid → consumed (oder refunded). | +| `payment_options` / `user_payment_options` | Legacy-Zahlungsvereinbarungen. Grandfathered-Einträge tragen die Netto-Vertragsbasis in `legacy_conditions`; versteckte Katalog-Platzhalter `LEGACY-{PE\|BP}-{Artikel}`. | +| `invoice_number_sequences` | Fortlaufende Nummern pro Rechnungskreis. | + +**Submit-Gate** (`User::hasActiveBooking()`, hinter `billing.enforce_booking`): +Eine aktive Buchung ist ein Cashier-Abo **oder** ein bezahlter, noch nicht +eingelöster Einzel-/Extra-PM-Kauf **oder** eine aktive/grandfathered +Legacy-Vereinbarung. Bestandskunden behalten damit nach Gate-Aktivierung +volle Einreichungsrechte. + +**PM-Kontingent** (`User::pressReleaseQuotaRemaining()`, null = unbegrenzt): + +| Wer | Kontingent | +|---|---| +| Launch-Schalter `billing.enforce_booking` aus | unbegrenzt (Vor-Launch-Zustand) | +| Bestandskunde (aktive/grandfathered Legacy-Vereinbarung) | **unbegrenzt** — Entscheidung 12.06.2026: Bestandsschutz gilt, das Alt-Produkt sah unbegrenzte PMs vor; eine Migration auf neue Tarife kommt ggf. später | +| Abonnent | Monatskontingent des Tarifs (`plans.press_release_quota`) plus offene Einmalkäufe | +| Nur Einmalkäufe | Anzahl bezahlter, noch nicht eingelöster Einzel-/Extra-PM-Käufe | + +**Slot-Verbrauch** (erst bei Veröffentlichung, Decision-Update §3.2; Ablehnung +kostet nichts; Re-Publish nach Archivierung zählt nicht doppelt): zuerst das +Plan-Monatskontingent (Zähler `users.press_release_quota_used_this_month`, +monatlicher Reset), danach wird der älteste bezahlte Einmalkauf eingelöst +(`single_purchases.status → consumed`, verknüpft mit der PM). Die frühere +Stub-Spalte `users.press_release_quota` ist entfernt. + +**Checkout-Einstiege** (Phase 9E/9F — verdrahtet auf der Buchungs-Seite): + +| Route | Zweck | +|---|---| +| `me.checkout.subscription` (`/admin/me/checkout/abo/{slug}/{monthly\|yearly}`) | Stripe-Checkout für ein Tarif-Abo | +| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) | +| `me.checkout.billing-portal` (`/admin/me/checkout/abo-verwalten`) | Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung) | + +**Buchungs-Voraussetzung** (12.06.2026): Jeder Checkout erfordert eine +vollständige Rechnungsadresse (`User::hasCompleteBillingAddress()`, +Pflicht: Nachname, Straße, PLZ, Ort, Land) — sonst Redirect aufs Profil +mit Hinweis. Lokale Adresse (`User::stripeAddress()`) und USt-ID +(`StripeCheckoutService::syncTaxIdFromBillingAddress`, Typ `eu_vat`) +werden an den Stripe-Customer übergeben. + +Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`). +Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()` +im AppServiceProvider, Netto-Preise mit `tax_behavior: exclusive`) — nach +denselben Regeln wie der VatResolver im MAN-Kreis, inkl. USt-ID-Abfrage im +Checkout. + +--- + +## 3. MAN-Kreis: Fälligkeitslauf für Legacy-Zahlungen + +Täglicher Scheduler-Lauf (04:30): `billing:generate-manual-invoices` + +1. Findet `user_payment_options` mit Status `active`/`grandfathered`, + **ohne** `stripe_subscription_id`, deren `current_period_end` erreicht ist. +2. Friert die Rechnungsadresse als Snapshot ein (`invoice_billing_addresses`, + inkl. `vat_id`). +3. Stellt die Rechnung aus: Netto-Basis × USt-Regel (Abschnitt 4), + MAN-Nummer, Zahlungsziel `billing.manual_due_days` (Default 14 Tage). +4. Schaltet die Periode weiter (`monthly`/`yearly` aus `legacy_conditions` + bzw. `payment_options.interval`). + +Nicht abrechenbare Fälle (fehlende Rechnungsadresse, kein Intervall) werden +geloggt, **die Periode bleibt stehen** — der nächste Lauf versucht es erneut. +Optionen: `--dry-run`, `--limit=50`. + +**Befüllung**: `legacy:grandfather-subscriptions` (Migrations-Runbook, nach +`legacy:archive-invoices`) leitet die aktiven jährlichen Vereinbarungen aus +dem Rechnungsarchiv ab — Replay-fähig für den Lauf kurz vor Relaunch. +Details: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. + +--- + +## 4. USt-Behandlung (Entscheidung 12.06.2026) + +**Alle neuen Preise sind Netto-Preise.** Die Steuer wird zur +Rechnungsstellung über `App\Services\Billing\VatResolver` aus der +Rechnungsadresse bestimmt: + +| Fall | Behandlung | Rechnung | +|---|---|---| +| Deutschland | immer mit Steuer (`billing.vat_rate`, Default 19 %) | Netto + USt ausgewiesen | +| EU mit gültiger USt-ID | befreit (Reverse Charge) | `is_netto`, Pflichthinweis in `tax_note` | +| EU ohne USt-ID | mit Steuer | Netto + USt ausgewiesen | +| Drittland | grundsätzlich befreit | `is_netto`, Hinweis „nicht im Inland steuerbar" | + +- Die USt-ID wird im Profil gepflegt (bestehendes Feld) und zusätzlich an + der Rechnungsadresse (`billing_addresses.vat_id`) gespeichert; jede + Rechnung friert sie im Adress-Snapshot ein. +- „Gültig" = vorhanden + formal plausibel (Länder-Präfix, EL für + Griechenland). **Offen: echte VIES-Validierung** — vor Aktivierung von + Gate/Checkout umsetzen. +- **Legacy-Umrechnung**: Das Altsystem fakturierte brutto (199 € inkl. + Steuer; Befreite mit Netto-Ausweis 167,23 €). Die Grandfather-Migration + leitet daraus die Netto-Basis ab (`legacy_conditions.net_cents`) — für + deutsche Bestandskunden bleibt der Bruttobetrag unverändert, die Steuer + wird künftig nur sauber ausgewiesen. + +--- + +## 5. Befehle & Scheduler + +| Befehl | Zweck | Scheduler | +|---|---|---| +| `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 | +| `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell | +| — Admin-UI: `/admin/payments/plans` | Tarif-Pflege (Preise, Kontingent, Tageslimit, aktiv/inaktiv) mit Sofort-Sync nach Stripe (`StripePlanSyncService`): Preisänderung legt ein neues Price-Objekt an und deaktiviert das alte; Bestandsabos behalten ihren Preis | — | +| `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) | +| `press-releases:reset-monthly-quota` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 | + +--- + +## 6. Konfiguration + +`config/billing.php`: + +| Schlüssel | ENV | Default | Bedeutung | +|---|---|---|---| +| `enforce_booking` | `BILLING_ENFORCE_BOOKING` | `false` | Submit-Gate scharf schalten (Launch-Schalter) | +| `invoice_number_padding` | — | `5` | Stellen der laufenden Nummer | +| `manual_due_days` | `BILLING_MANUAL_DUE_DAYS` | `14` | Zahlungsziel MAN-Rechnungen | +| `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle | +| `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung | + +Stripe/Cashier: + +| ENV | Bedeutung | +|---|---| +| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) | +| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) | +| `STRIPE_PRICE_SINGLE_PM` | Stripe-Price-ID der Einzel-PM (legt `billing:sync-stripe-plans` an; ohne sie ist der Einzel-PM-Checkout deaktiviert) | +| `BILLING_OWN_VAT_ID` | Eigene deutsche USt-ID des Betreibers — schaltet die eVatR-Online-Bestätigung ausländischer EU-USt-IDs frei (BZSt-REST-API, `VatIdValidationService`); ohne sie bleibt es bei der Formatprüfung | +| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) | + +**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded` +(STR-Spiegelung), `checkout.session.completed` (Einmalkauf-Erfüllung) sowie +die Cashier-Standardevents `customer.subscription.created/updated/deleted`, +`customer.updated`, `customer.deleted` (Abo-Zustand). + +**Lokal testen** (der registrierte Endpoint zeigt auf die Live-Domain und +läuft bis zum Relaunch ins Leere — das ist unkritisch, Stripe versucht die +Zustellung nur erneut): Stripe CLI verwenden — +`stripe listen --forward-to pressekonto.test/stripe/webhook` und das von der +CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`. + +--- + +## 7. Offene Punkte (Stand 12.06.2026, nach Phase 9E) + +0. **9E erledigt**: Stripe-Sync (Tarife + Einzel-PM als Netto-Produkte, + Test-Mode), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, + Endpoint + Secret eingerichtet), Checkout-Flows (Abo + Einzel-PM, + Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt + (Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte + entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`). +1. **Phase 9F erledigt** (12.06.2026): Die Buchungs-Seite zeigt das echte + Tarif-Raster (Monat/Jahr-Toggle), den Einzel-PM-Block, Bestandstarife + und „Abo verwalten" (Stripe Billing Portal, `me.checkout.billing-portal`). +1b. **Admin-Zahlungsmodul erledigt** (12.06.2026): `/admin/payments` zeigt + KPIs (aktive Abos, MRR netto, Umsatz 30 Tage, offene Einzel-PMs) plus + Abo-, Einmalkauf- und Rechnungstabellen (STR/MAN) mit User-Suche; + `/admin/payments/plans` pflegt die Tarife mit Sofort-Sync nach Stripe + (Abschnitt 5). Refund-Workflow direkt aus dem Admin bleibt offen + (vorerst über das Stripe-Dashboard). +2. **Stripe Tax**: im Dashboard aktiviert (12.06.2026, Produkt-Steuercode + „SaaS – business use", Steuer nicht im Preis enthalten — passt zu den + Netto-Preisen). Vor Relaunch im **Live-Mode** wiederholen; dort auch + `billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs). +3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft + die im Checkout erfasste USt-ID asynchron selbst). +4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`); + Archiv-PDFs existieren bereits on-demand. +5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung + auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird + das unbegrenzte Bestandskontingent ggf. migriert. +6. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem + ersten produktiven MAN-/STR-Lauf. +7. **Tageslimit** (`plans.daily_limit`) durchsetzen — Phase 9G. diff --git a/docs/user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md b/docs/user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md new file mode 100644 index 0000000..4a90214 --- /dev/null +++ b/docs/user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md @@ -0,0 +1,524 @@ +# Entwicklungsplan: KI-Prüfung & Veröffentlichungs-Pipeline + +Stand: 11.06.2026 — **Phasen 0–5 abgeschlossen**, Phase 6 (Trust-Score) offen. + +Dieser Plan definiert die schrittweise Umsetzung der automatisierten Prüfung +und Veröffentlichung von Pressemitteilungen (PM). Er ist so geschnitten, dass +jede Phase einzeln umgesetzt, getestet und ausgeliefert werden kann. + +> **Abgleich mit dem Decision-Update (11.06.2026)**: Das +> [`Decision-Update Preisstruktur & Veröffentlichungs-Flow`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) +> setzt auf dieser Pipeline auf und ergänzt zum Launch drei noch offene +> Flow-Regeln, die **nicht** Teil dieses Plans waren: +> +> 1. **Submit-Gate**: „Zur Prüfung einreichen" wird hinter eine aktive +> Buchung gelegt (das Modal zeigt ohne Buchung einen Buchungs-Hinweis). +> 2. **Slot-Verbrauch bei Veröffentlichung** statt bei Einreichung — +> rot abgelehnte PMs verbrauchen keinen Slot. Der aktuelle Quota-Stub +> zählt noch beim Einreichen (`submitForReview`) und muss umgestellt werden. +> 3. **Kein Re-Check zum Launch**: eine Einreichung = eine Prüfung; +> Nachbessern + erneut prüfen kommt erst in Phase 2. +> 4. **Gelb-Routing geändert (Entscheidung 12.06.2026)**: Gelb geht zum +> Launch **direkt live** wie Grün — keine manuelle Review-Queue mehr. +> Nur Rot wird abgelehnt (mit Begründung an den Autor). Phase 4 unten +> beschreibt das ursprünglich gebaute Verhalten (Gelb → manuelle Queue); +> die Umstellung erfolgt im Phase-9-Plan +> (`docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`, Päckchen 9A). + +## Ziel & Leitprinzip + +Jede eingehende PM wird **automatisch von einer KI geprüft**. Nur in +**äußersten Fällen** erfolgt eine manuelle redaktionelle Prüfung. Zwei +Einreichungsstellen müssen denselben Prüf-Pfad durchlaufen: + +1. **Web-Formular** (Customer- und Admin-Editor) +2. **API** (`/api/v1/press-releases`) + +Es gibt zwei voneinander unabhängige Bewertungen (Konzept-Update 1, Abschnitt 15): + +- **Klassifikations-Score (Grün/Gelb/Rot)** — der „Red Flag": entscheidet, ob + überhaupt veröffentlicht wird. **Jetzt umzusetzen.** +- **Content-Score (0–100) → Stufe (Standard/Geprüft/Hochwertig)** — die + Qualitätsbewertung („Scoring"). **Spätere Phase.** + +## Konzept-Grundlage + +- `docs/konzept/Konzept-Update 1 – Überarbeitete Abschnitte.md`, §15.1 + (Klassifikations-Score) und §15.2 (Content-Score). +- `docs/konzept/Konzept-Update 2 – Score-Stufen-System.md` (Stufen-Mapping, + `content_tier`, Außenkommunikation). + +Klassifikations-Score laut Konzept §15.1: + +| Klassifikation | Bedeutung | Auswirkung | +|---|---|---| +| **Grün** | unauffällig | direkte Veröffentlichung (optional 5–10 Min. Verzögerung) | +| **Gelb** | unklar/grenzwertig | manuelle Review-Queue (nicht boostbar) | +| **Rot** | unzulässig | zurück an Autor mit Begründung, keine Veröffentlichung | + +Faktoren (Red Flags): Werbung statt PM, beleidigend/diskriminierend, rechtlich +heikel, Spam-Muster, unseriöse Versprechen. Speicherung laut Konzept: +`press_releases.classification` plus Audit-Log `ki_audits`. + +## Ist-Zustand (Bestandsaufnahme) + +- **Statuswerte** (`App\Enums\PressReleaseStatus`): `draft`, `review`, + `published`, `rejected`, `archived`. +- **Web-Einreichung**: `App\Services\PressRelease\PressReleaseService::submitForReview()` + prüft nur eine wortbasierte Blacklist (`config/blacklist.php` via + `BlacklistService`), setzt sonst Status `review`, erhöht das Quota und + schreibt ein `PressReleaseStatusLog`. +- **Veröffentlichung**: `PressReleaseService::publish()` (Admin-Aktion) und der + Cron `App\Console\Commands\PublishScheduledPressReleases` (publiziert + `review`-PMs mit fälligem `scheduled_at`). Beide prüfen erneut nur die + Blacklist. +- **API**: `App\Http\Controllers\Api\V1\PressReleaseController::store()` / + `update()` schreiben `status` direkt aus dem Request (erlaubt: `draft`, + `review`) und rufen `submitForReview` **nicht** auf. Eine API-PM mit + `status=review` landet damit **ohne** Blacklist-/Quota-/Log-Prüfung in der + Queue. +- **UI-Einreichung (Prozess-Start)**: + - Detailansicht ([show.blade.php]): vollständiges Modal `confirm-submit-review` + mit rechtlichen Hinweisen, Quota-Anzeige und Bestätigungs-Checkboxen → + ruft `submitForReview`. + - Bearbeiten ([edit.blade.php]): Button „Speichern & zur Prüfung" mit nur + `wire:confirm` (Browser-Dialog), **kein** Modal. + - Erstellen ([create.blade.php]): Button „Zur Prüfung senden" ohne Modal. +- **Kein** Datenmodell für Klassifikation/Score: keine Spalten + `classification`, `content_score`, `content_tier`, keine Tabelle `ki_audits`. + +## Lücken & Risiken + +- **L1 — API-Bypass**: Einreichung über die API umgeht jede Prüfung. +- **L2 — Keine echte Inhaltsprüfung**: nur eine triviale Wort-Blacklist; keine + Erkennung von Werbung, Spam, rechtlich heiklen oder unseriösen Inhalten. +- **L3 — Auto-Publish ohne Klassifikation**: geplante PMs werden vom Cron + veröffentlicht, ohne dass eine inhaltliche Bewertung stattgefunden hat. +- **L4 — Uneinheitlicher Prozess-Start**: das Bestätigungs-Modal existiert nur + in der Detailansicht, nicht beim Bearbeiten/Erstellen. +- **L5 — Kein Audit**: KI-Entscheidungen wären ohne `ki_audits` nicht + nachvollziehbar (DSGVO / Nachweispflicht). + +## Zielarchitektur + +``` +Einreichung (Formular ODER API) + │ + ▼ + SubmissionService.submit() ← ein einziger Funnel + │ + ├─ Hard-Filter: Blacklist (synchron, deterministisch) + ▼ + ClassificationService.classify() ← KI (Claude), mit Fallback + │ + ├─ Rot → status=rejected, Begründung an Autor + ├─ Gelb → status=review (manuelle Queue, „äußerste Fälle") + └─ Grün → Veröffentlichungspfad (sofort / geplant) + │ + ▼ + ki_audits (vollständiges Audit-Log jeder KI-Entscheidung) + + + press_releases.classification / classified_at + + + (später) content_score / content_tier +``` + +Kernregeln: + +- Formular **und** API rufen ausschließlich `SubmissionService.submit()` auf. + Die API darf `status` nicht mehr frei setzen; `published` ist über die API + nie erreichbar. +- Re-Klassifikation bei jeder Änderung einer PM (Konzept §15.1: „Bei Änderung + der PM wird neu klassifiziert"). +- Schwellen/Verhalten sind konfigurierbar (`config/scoring.php`), damit sie + ohne Code-Änderung kalibriert werden können. + +--- + +## Entwicklungsschritte + +### Phase 0 — Prozess-Start im UI vereinheitlichen — ✅ erledigt (11.06.2026) + +**Ziel:** Das bestehende Einreichungs-Modal erscheint überall dort, wo eine PM +eingereicht wird — auch beim Bearbeiten (Button „Speichern & zur Prüfung") und +beim Erstellen (Button „Zur Prüfung senden"). Reiner UI-Schritt, kein Backend. + +**Umsetzung:** Das Modal `confirm-submit-review` (rechtliche Hinweise, Quota, +Bestätigungs-Checkboxen) wird in Customer-Show, -Create **und** -Edit über +`flux:modal.trigger` geöffnet; bestätigt ruft es wie geplant +`submitForReview` bzw. `saveAndSubmit`/`save('review')`. + +**Umfang:** + +- Modal `confirm-submit-review` aus `show.blade.php` in eine wiederverwendbare + Blade-/Volt-Komponente extrahieren (z. B. + `resources/views/livewire/components/press-release-submit-modal.blade.php`). +- In `edit.blade.php` den `wire:confirm`-Button durch einen + `flux:modal.trigger` ersetzen; bei Bestätigung wird wie bisher + `saveAndSubmit` ausgeführt (erst speichern, dann einreichen). +- In `create.blade.php` denselben Modal-Trigger vor `save('review')` schalten. +- Texte/Checkboxen identisch zur Detailansicht halten (rechtliche Hinweise, + Quota, Bestätigungen). + +**Betroffene Dateien:** `resources/views/livewire/customer/press-releases/{show,edit,create}.blade.php`, +neue Komponente unter `resources/views/livewire/components/`. + +**Done:** In allen drei Ansichten (Customer: show/edit/create) öffnet derselbe +Bestätigungsdialog; Tests für Edit/Create-Submit grün. + +**Admin-Editor (`/admin/press-releases/`) — bewusst ausgenommen:** Der +Admin-Editor behält sein bisheriges Verhalten (`wire:confirm`). Begründung: Wenn +eine PM beim Admin landet, hat die vorgelagerte User-Prüfung (Einreichungs-Modal +im Customer-Flow) bereits stattgefunden. Der Admin braucht hier keinen erneuten +Bestätigungsdialog. Stattdessen erhält der Admin-Editor in einer späteren Phase +einen zusätzlichen **„Prüfung"-Button** (siehe Phase 4: On-Demand-KI-Prüfung). + +**Tests:** Volt-Tests, die das Öffnen des Modals und den Submit-Pfad +(`saveAndSubmit` / `save('review')`) abdecken. + +### Phase 1 — Einreichungs-Funnel & API-Absicherung — ✅ erledigt (11.06.2026) + +**Ziel:** Beide Einreichungsstellen laufen durch einen Pfad; die API-Lücke (L1) +wird geschlossen. Noch ohne KI — nur Vereinheitlichung. + +**Umsetzung:** + +- `PressReleaseService::submitForReview()` ist der alleinige Einreichungs-Einstieg + (Web-Formular **und** API rufen dieselbe Methode). Auf eine separate + `SubmissionService`-Fassade wurde bewusst verzichtet — `submitForReview` ist + bereits die stabile Schnittstelle, in die Phase 3 die KI-Klassifikation + einhängt. +- API: `status` aus den Validierungsregeln von `StorePressReleaseRequest` und + `UpdatePressReleaseRequest` entfernt (inkl. ungenutzter Imports). `store()` + erzeugt jetzt **immer** `PressReleaseStatus::Draft`; ein übergebenes `status` + wird ignoriert. `update()` kann den Status nicht mehr setzen. +- Neue explizite Route `POST /api/v1/press-releases/{pressRelease}/submit` + (`press-releases.submit`) → `PressReleaseController::submit()`. Diese prüft + `press-releases:write`, Ownership und erlaubt nur `draft`/`rejected` + (sonst 409); ruft `submitForReview()`; eine `BlacklistViolationException` + wird als **422** mit Begründung zurückgegeben. Damit greifen Blacklist-, + Quota- und Status-Log-Behandlung auch für API-Einreichungen. +- `published` ist über die API weiterhin nie erreichbar (nur Admin-Aktion/Cron). + +**Betroffene Dateien:** `app/Http/Controllers/Api/V1/PressReleaseController.php`, +`app/Http/Requests/Api/V1/{Store,Update}PressReleaseRequest.php`, +`routes/api.php`. `PressReleaseService` blieb unverändert (Schnittstelle +ausreichend). + +**Done:** API kann keine PM mehr ungeprüft in `review` heben; eine PM-Einreichung +verhält sich über API und Formular identisch. + +**Tests:** `tests/Feature/Api/V1/PressReleaseSubmitApiTest.php` (Create erzeugt +immer Draft & ignoriert `status`; Submit-Route hebt nach `review`, zählt Quota, +schreibt Log; Blacklist → 422 + `rejected`; fehlende Schreibrechte → 403; +bereits in `review` → 409; fremde PM → 403). Alle grün. + +### Phase 2 — Datenmodell & Audit — ✅ erledigt (11.06.2026) + +**Ziel:** Persistenz für Klassifikation und vollständiges KI-Audit. Noch ohne +Verhaltensänderung (alle Felder nullable). + +**Umsetzung:** + +- Migration `add_classification_to_press_releases`: Spalten `classification` + (string(16), nullable, nach `status`) und `classified_at` (timestamp, + nullable) plus Index auf `classification`. `content_score`/`content_tier` + bewusst **erst in Phase 5** (siehe Datenmodell-Anhang). +- Migration `create_ki_audits_table`: `press_release_id` (FK, cascade), + `type`, `provider` (nullable), `model` (nullable), `result` (nullable), + `reason` (text, nullable), `raw_response` (json, nullable), + `created_at` (useCurrent), Index `(press_release_id, type)`. Kein + `updated_at` (append-only Log). +- Model `App\Models\KiAudit` (`$timestamps = false`, Cast `raw_response` → + array, Konstanten `TYPE_CLASSIFICATION`/`TYPE_CONTENT_SCORE`, Relation + `pressRelease()`), Relation `PressRelease::kiAudits()` (neueste zuerst). +- Enum `App\Enums\PressReleaseClassification` (Green/Yellow/Red + `label()`), + in `PressRelease::casts()` für `classification` registriert. +- `config/scoring.php`: Anbieter/Modell-Auswahl (`CLASSIFICATION_PROVIDER`, + Default `deterministic`, `CLASSIFICATION_MODEL`), Timeout, Grün-Verzögerung + (Minuten), Gelb→manuelle-Queue-Flag sowie Content-Score-Stufen-Schwellen + (Phase 5). +- `KiAuditFactory` mit States `classification()` / `contentScore()`. + +**Betroffene Dateien:** zwei neue Migrationen unter `database/migrations/`, +`app/Models/PressRelease.php`, `app/Models/KiAudit.php`, +`app/Enums/PressReleaseClassification.php`, `config/scoring.php`, +`database/factories/KiAuditFactory.php`. + +**Done:** Migrationen laufen; Modelle/Casts/Relation vorhanden; keine bestehende +Funktionalität verändert (alle Felder nullable). + +**Tests:** `tests/Feature/PressReleaseClassificationModelTest.php` (Enum-/ +Datetime-Cast, Default null, `kiAudits()`-Reihenfolge, `raw_response`-Array-Cast ++ Relation, Cascade-Delete). Alle grün. + +### Phase 3 — KI-Klassifikation (Red Flag) — ✅ erledigt (11.06.2026) + +**Ziel:** Echte inhaltliche Prüfung jeder Einreichung; Ergebnis asynchron als +Klassifikation gespeichert und auditiert. + +**Entscheidungen (11.06.2026):** Erster aktiver Anbieter ist **OpenAI** (Key/ +Budget vorhanden); Anthropic/Gemini folgen über dieselbe Treiber-Schnittstelle. +Klassifikation läuft **asynchron über die Queue** (synchron wäre später nicht +handelbar). Zum Testen ohne Dauer-Worker gibt es einen Drain-Befehl. + +**Umsetzung:** + +- **Provider-agnostische Treiber-Architektur** unter + `app/Services/PressRelease/Classification/`: + - Interface `Contracts\ClassificationDriver::classify(PressRelease): ClassificationResult`. + - `ClassificationResult` (Value Object: Enum-Klassifikation, `reasons[]`, + `provider`, `model`, `rawResponse`, `reasonText()`). + - `Drivers\OpenAiClassificationDriver` — OpenAI Chat-Completions via + `Http`-Client, liest `config/services.openai` (Key/URL/Modell/Timeout), + erzwingt `response_format: json_object` und parst + `{classification, reasons[]}`. Wirft bei fehlendem Key / HTTP-Fehler / + ungültigem JSON. + - `Drivers\DeterministicClassificationDriver` — Blacklist → Rot/Grün + (nie Gelb), als Fallback ohne externe API. + - `ClassificationManager` (Laravel-Manager) löst den Treiber aus + `config('scoring.classification.provider')` auf + (`createOpenaiDriver`/`createDeterministicDriver`). +- **Asynchroner Job** `app/Jobs/ClassifyPressRelease` (Queue `classification`, + `tries=3`): klassifiziert über den aktiven Treiber, bei Ausfall **Fallback** + auf den deterministischen Treiber (mit `Log::warning`), schreibt + `press_releases.classification`/`classified_at` und einen `ki_audits`-Eintrag + (inkl. `provider`/`model`/`reason`/`raw_response`). +- **Einbindung in den Funnel:** `PressReleaseService::submitForReview()` stößt + nach dem synchronen Blacklist-Hard-Filter und dem Statuswechsel den Job an + (`ClassifyPressRelease::dispatch(...)->onQueue('classification')`). Greift für + Formular **und** API (gemeinsamer Einstieg aus Phase 1). +- **Drain-Befehl** `php artisan classification:work` (Option `--once`): arbeitet + die Queue einmalig ab und beendet sich (`queue:work --stop-when-empty`) — zum + Testen ohne permanenten Worker. +- **Konfig:** `config/scoring.php` Default-Provider auf `openai` gesetzt + (`CLASSIFICATION_PROVIDER`); Modell leer ⇒ `config('services.openai.model')`. +- **Test-Isolation:** `phpunit.xml` erzwingt `CLASSIFICATION_PROVIDER=deterministic` + und leeren `OPENAI_API_KEY`, damit die Suite keine echten OpenAI-Calls macht; + der OpenAI-Pfad wird gezielt mit `Http::fake()` getestet. + +**Noch offen (bewusst):** Re-Klassifikation bei jeder PM-Änderung (Update über +Formular/API) ist noch **nicht** verdrahtet — Phase 3 klassifiziert beim +Einreichen. Nachzuziehen, wenn das Status-Routing (Phase 4) steht. Anthropic-/ +Gemini-Treiber + SDK folgen separat. + +**Betroffene Dateien:** `app/Services/PressRelease/Classification/*`, +`app/Jobs/ClassifyPressRelease.php`, +`app/Console/Commands/RunClassificationQueue.php`, +`app/Services/PressRelease/PressReleaseService.php`, `config/scoring.php`, +`phpunit.xml`. + +**Done:** Jede Einreichung (Formular + API) stößt asynchron eine Klassifikation +an, erzeugt einen `ki_audits`-Eintrag; bei KI-Ausfall greift der deterministische +Fallback nachvollziehbar. Status-Routing folgt in Phase 4. + +**Tests:** `tests/Feature/PressReleaseClassificationJobTest.php` (OpenAI grün/gelb +mit `Http::fake`, Fallback bei HTTP-500, deterministisch Rot bei Blacklist, +Dispatch auf Queue `classification` via `Queue::fake`). Alle grün; volle Suite +416 grün (2 vorbestehende WIP-Failures unverändert). + +### Phase 4 — Routing, Auto-Publish & Review-Queue — ✅ erledigt (11.06.2026) + +**Ziel:** Die Klassifikation steuert den Status. Manuelle Prüfung nur noch bei +Gelb. + +**Umsetzung:** + +- **Routing im Job** über `PressReleaseService::routeByClassification()` + (vom `ClassifyPressRelease`-Job nach dem Klassifizieren aufgerufen): + - **Rot** → `reject(..., source: 'ki')`: `status=rejected`, KI-Begründung per + Mail an den Autor (`PressReleaseRejected`, wie bei Blacklist). + - **Gelb** → keine Aktion, bleibt `review` (manuelle Admin-Queue). + - **Grün** → `autoPublishGreen()`: ohne Termin sofort veröffentlichen, + optional mit Sicherheitsfenster `scoring.classification.green_delay_minutes` + (über `published_at`-Override); mit zukünftigem `scheduled_at` bleibt die PM + in `review` und der Scheduler publiziert zum Termin. + - Greift nur, solange die PM noch `review` ist (manuelle Admin-Eingriffe haben + Vorrang). `publish()` erhielt einen `?Carbon $publishedAtOverride`-Parameter, + `reject()` einen `string $source`-Parameter. +- **Scheduler** `PublishScheduledPressReleases`: Kandidaten-Query um + `where('classification', 'green')` erweitert — nur **grüne** fällige PMs + werden automatisch publiziert; gelbe warten immer auf den Admin. Geplante + Termine werden weiterhin respektiert. +- **Admin-Review-Queue:** Index- und Show-Ansicht zeigen ein KI-Klassifikations- + Badge (grün/gelb/rot); der Index hat einen **Klassifikations-Filter** + (`classificationFilter`, inkl. URL-Param, Active-Chip, Reset) — damit „nur + Gelb" filterbar. Die Show-Ansicht blendet im Review-Block den **KI-Hinweis** + (Begründung aus dem jüngsten `ki_audits`-Eintrag) ein. + +**Test-Isolation (wichtig):** Da Tests mit `sync`-Queue den Job inline ausführen, +wurde der Klassifikations-Job in den „submit→review"-Tests via `Queue::fake()` +entkoppelt (Workflow, PublishModal, API-Submit). Die Scheduler-Tests setzen jetzt +`classification = green` für Publish-Kandidaten; neuer Test: fällige **gelbe** PM +bleibt `review`. + +**Betroffene Dateien:** `app/Services/PressRelease/PressReleaseService.php`, +`app/Jobs/ClassifyPressRelease.php`, +`app/Console/Commands/PublishScheduledPressReleases.php`, Admin-Views +`resources/views/livewire/admin/press-releases/{index,show}.blade.php`. + +**Done:** Grüne PMs gehen automatisch live (sofort/zum Termin), rote werden +abgelehnt + Autor benachrichtigt, nur gelbe landen in der manuellen Queue; Admin +sieht Klassifikation + KI-Begründung und kann nach Gelb filtern. + +**Tests:** Routing in `PressReleaseClassificationJobTest` (Rot→rejected+Mail, +Grün-sofort→published+Mail, Grün-geplant→bleibt review, Gelb→bleibt review); +Scheduler in `PressReleaseSchedulingTest` (grün fällig→published, gelb +fällig→review); Admin-UI in `PressReleaseIndexPhase8bTest` (KI-Badge, +Klassifikations-Filter). Volle Suite 423 grün (2 vorbestehende WIP-Failures). + +**Admin „Prüfung"-Button (On-Demand-KI-Prüfung)** — ✅ erledigt (11.06.2026): + +- Im Admin-Editor gibt es oben den Button **„Prüfung"**, der ein Modal + `admin-ki-check` öffnet: auswählbare Klassifikation (Content-Score als + „in Vorbereitung" deaktiviert) und ein **Anbieter-Override** (Konfiguriert / + OpenAI / Deterministisch). +- `runKiCheck()` dispatcht `ClassifyPressRelease` auf der Queue `classification` + **mit `route: false`** und optionalem `providerOverride`. Das ist eine + nachgelagerte Re-Check-Prüfung: sie aktualisiert nur `classification` + + `ki_audits`, **ohne** den Status zu ändern (kein Auto-Publish/Reject) — die + Entscheidung bleibt beim Admin (Ergebnis sichtbar in der Detailansicht). +- Dafür erhielt `ClassifyPressRelease` die Parameter `bool $route = true` und + `?string $providerOverride = null`. + +**Tests:** `tests/Feature/Admin/AdminKiCheckTest.php` (Button/Modal sichtbar; +Dispatch mit `route=false` + Provider-Override; Abbruch ohne Auswahl; +Re-Check-Job aktualisiert Bewertung, lässt Status unverändert). + +**Re-Klassifikation bei Änderung** (Konzept §15.1) — ✅ erledigt (11.06.2026): + +- Neue Service-Methode `reclassifyIfClassified()`: dispatcht – nur wenn die PM + bereits klassifiziert ist – `ClassifyPressRelease` mit `route: false` + (Re-Check ohne Statusänderung). +- Eingehängt überall dort, wo Inhalt geändert wird, und nur bei tatsächlicher + Änderung von Titel/Text (`wasChanged(['title', 'text'])`): + Customer-Editor `save()`, Admin-Editor `save()`, API `update()`. + Beim Einreichen übernimmt weiterhin `submitForReview` die (routende) + Klassifikation. + +**Tests:** `tests/Feature/PressReleaseReclassifyTest.php` (Service dispatcht nur +bei vorhandener Klassifikation; API-Update klassifiziert neu bei Text-Änderung, +nicht bei reiner Keyword-Änderung). + +**Noch offen / Folgearbeiten:** + +- **Live-Aktualisierung der Ansicht** nach Abschluss des Hintergrund-Jobs + (Polling/Event) wäre ein optionales UX-Upgrade; aktuell erscheint das Ergebnis + nach Reload/Navigation. +- **Content-Score-Option** im Prüfungs-Modal — ✅ mit Phase 5 aktiviert (s. u.). + +### Phase 5 — Content-Score & Stufen — ✅ erledigt (11.06.2026) + +**Ziel:** Qualitätsbewertung 0–100 → Stufe Standard/Geprüft/Hochwertig +(Konzept-Update 2). + +**Umsetzung:** + +- **Datenmodell:** Migration `add_content_score_to_press_releases` — + `content_score` (tinyint, nullable), `content_tier` (string, nullable, Index), + `scored_at`. Enum `App\Enums\PressReleaseContentTier` + (Standard/Geprueft/Hochwertig) mit `fromScore()` (Schwellen aus + `config/scoring.php`), `label()` und `isPubliclyBadged()` (Standard wird laut + Update 2 nicht beworben). In `PressRelease` als Cast registriert. +- **Schwellen** (`config/scoring.php`): Geprüft ≥ 60, Hochwertig ≥ 80 (Update 2), + kalibrierbar; plus Anbieter/Modell/Timeout für den Score. +- **Treiber-Architektur** unter `app/Services/PressRelease/ContentScore/` + analog zur Klassifikation: `Contracts\ContentScoreDriver`, `ContentScoreResult`, + `Drivers\OpenAiContentScoreDriver` (gewichtete Faktoren §15.2 als JSON + `{score, breakdown}`), `Drivers\DeterministicContentScoreDriver` + (regelbasierte Heuristik: Länge, Bild, Quelle, Headline, Vollständigkeit), + `ContentScoreManager`. +- **Job** `app/Jobs/ScorePressRelease` (Queue `classification`, Fallback auf + deterministisch): schreibt `content_score` + abgeleitete `content_tier` + + `scored_at` und `ki_audits` (type=content_score). Optionaler + `providerOverride`. +- **Berechnung** bei Einreichung (`submitForReview` dispatcht Klassifikation + **und** Score) und bei Inhaltsänderung (`rescoreIfScored()` in Customer-/ + Admin-Editor und API-`update()`, analog zur Re-Klassifikation). +- **Anzeige:** + - Customer-Editor: Score-Panel (Punktzahl, Stufe, „noch X Punkte bis zur + nächsten Stufe") — der produktive Editor-Score laut Update 2. + - Admin-Index & -Show: Stufen-/Score-Badge (intern inkl. Punktzahl). + - Customer-Detailansicht: öffentliches Stufen-Badge (✓ Geprüft / ★ Hochwertig; + Standard ohne Badge). +- **Admin-Prüfungs-Modal:** Content-Score-Option aktiviert; `runKiCheck()` + dispatcht zusätzlich `ScorePressRelease`. + +**Done:** Score wird bei Einreichung/Änderung berechnet, Stufe abgeleitet, +auditiert und überall sichtbar. + +**Tests:** `tests/Feature/PressReleaseContentScoreTest.php` (Tier-Mapping, +öffentliche Badges, OpenAI-Score→Tier+Audit, Fallback, Dispatch bei Submit, +Re-Score nur wenn bereits bewertet); Editor-Panel in +`CustomerPressReleaseEditPhase7Test`; Stufen-Badge in `PressReleaseIndexPhase8bTest`; +Content-Score-Dispatch in `AdminKiCheckTest`. Volle Suite 440 grün. + +**Noch offen / Folgearbeiten:** + +- **Public Web-Frontend** (presseecho/businessportal24): Stufen-Badges in den + öffentlichen Listen/Detailseiten gemäß Update 2 ergänzen (bisher nur im + Portal/Backend und der Customer-Ansicht). +- **Score-History & Breakdown-Ansicht** (Publisher-Dashboard) und Boost- + Eligibilität (Abschnitt 16) sind eigene spätere Ausbaustufen. + +### Phase 6 — Trust-Score (später) + +Account-/Firmen-Ebene (Konzept §15.3): lockert die KI-Freigabe-Schwelle für +zuverlässige Publisher. Eigene spätere Ausbaustufe; hier nur als Ausblick +vermerkt. + +--- + +## Datenmodell-Anhang (Zielzustand) + +``` +press_releases (Ergänzungen) + + classification enum(green,yellow,red) NULL + + classified_at timestamp NULL + + content_score tinyint NULL (Phase 5) + + content_tier enum(standard,gepruft,hochwertig) NULL (Phase 5) + +ki_audits (neu) + - id + - press_release_id FK + - type enum(classification,content_score) + - provider string (z. B. anthropic) + - model string (z. B. claude-opus-4-8) + - result string/json + - reason text NULL + - raw_response json/longtext + - created_at timestamp +``` + +## Offene Entscheidungen + +- **Anbieter & Modell** — ✅ entschieden (11.06.2026): Erster aktiver Anbieter + ist **OpenAI** (`CLASSIFICATION_PROVIDER=openai`, Modell aus + `config/services.openai`). Architektur provider-agnostisch; Anthropic/Gemini + folgen. Offen bleibt, ob später mehrere Anbieter parallel (Primär + Fallback + jenseits des deterministischen) laufen sollen. +- **Synchron vs. Queue** — ✅ entschieden (11.06.2026): **Queue** (asynchron, + Queue-Name `classification`). Drain zum Testen: `php artisan classification:work`. +- **Dependency**: OpenAI-Treiber nutzt den nativen `Http`-Client (kein neues + Composer-Paket). Anthropic PHP-SDK (`anthropic-ai/sdk`) ist **freigegeben**; + für Gemini je ein offizielles/etabliertes SDK oder HTTP-Client bei Umsetzung + des Treibers. +- **Grün-Verzögerung**: 0 Min. (sofort) oder 5–10 Min. (Konzept-Option) als + Sicherheitsfenster — konfigurierbar über + `scoring.classification.green_delay_minutes`, Default noch festzulegen. +- **Gelb-Verhalten**: ausschließlich manuelle Queue, oder zusätzlich + automatische Benachrichtigung des Autors. +- **DSGVO**: Aufbewahrung/Anonymisierung der `raw_response` in `ki_audits`. + +## Nächste Schritte + +Phasen 0–5 sind umgesetzt (Suite grün). Es folgen: + +1. **Launch-Block aus dem Decision-Update** (siehe Abgleich-Box oben): + Submit-Gate hinter Buchung, Slot-Verbrauch bei Veröffentlichung, + Tarif-/Zahlungs-Modul. +2. **Betrieb**: Queue-Worker für `classification` im Produktions-Setup + (Test-Drain: `php artisan classification:work`). +3. **Folgearbeiten**: Live-Aktualisierung des KI-Ergebnisses in der UI, + Stufen-Badges im öffentlichen Web-Frontend, Anthropic-/Gemini-Treiber. +4. **Phase 6**: Trust-Score (eigene Ausbaustufe). diff --git a/docs/user-admin/Lizenztyp Bildupload.md b/docs/user-admin/Lizenztyp Bildupload.md new file mode 100644 index 0000000..ff81b6b --- /dev/null +++ b/docs/user-admin/Lizenztyp Bildupload.md @@ -0,0 +1,207 @@ +Ich würde das Formular inhaltlich klarer machen und stärker gegen typische Bildrechts-Probleme absichern. Der aktuelle Ansatz ist gut, aber bei Presseportalen würde ich etwas präziser zwischen **Urheberrecht**, **Nutzungsrecht**, **Personenrechten** und **Quelle/Nachweis** unterscheiden. + +## **1. Lizenztyp-Auswahl überarbeiten** + +Aktuell hast du: + +- Eigene Aufnahme +- CC-Lizenz +- Kommerzielle Lizenz erworben +- Einwilligung des Urhebers +- Sonstiges + +Ich würde daraus eher machen: + +### **Empfohlene Lizenztypen** + +|**Option**|**Wann verwenden?**| +|---|---| +|**Eigene Aufnahme**|Der Uploadende hat das Bild selbst erstellt| +|**Vom Urheber / Fotografen freigegeben**|Direkte schriftliche Erlaubnis liegt vor| +|**Agentur-/Stockbild-Lizenz**|Bild wurde z. B. über Adobe Stock, Shutterstock, Getty etc. lizenziert| +|**Creative-Commons-Lizenz**|Bild steht unter CC BY, CC BY-SA, CC0 etc.| +|**Presse-/PR-Bild mit Nutzungsfreigabe**|Bild wurde z. B. von Unternehmen, Veranstaltern, Agenturen oder Pressestellen bereitgestellt| +|**Gemeinfrei / Public Domain / CC0**|Keine oder sehr weitgehende Nutzungsbeschränkungen| +|**Sonstige Lizenz / Sondervereinbarung**|Freitext erforderlich| + +„Einwilligung des Urhebers“ würde ich nicht als eigenen Lizenztyp stehen lassen, sondern eher als **„Vom Urheber/Fotografen freigegeben“** formulieren. Das ist verständlicher. + +## **2. Bei Creative Commons zusätzliche Felder anzeigen** + +Wenn jemand **CC-Lizenz** auswählt, sollte nicht nur „CC-Lizenz“ gespeichert werden. Du brauchst genauer: + +- CC0 +- CC BY +- CC BY-SA +- CC BY-ND +- CC BY-NC +- CC BY-NC-SA +- CC BY-NC-ND + +Wichtig: **NC** bedeutet „nicht-kommerziell“ und kann für ein Presseportal problematisch sein, besonders wenn die Seite werbefinanziert ist oder kommerziell betrieben wird. **ND** erlaubt keine Bearbeitung, also eventuell auch keinen Beschnitt als Titelbild. + +Daher würde ich bei CC-Lizenzen automatisch Hinweise anzeigen, zum Beispiel: + +Diese Lizenz kann Einschränkungen enthalten. Bitte prüfen, ob kommerzielle Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt sind. + +## **3. „Urheber / Fotograf“ verpflichtender machen** + +Das Feld **Urheber / Fotograf** sollte in den meisten Fällen Pflicht sein, außer vielleicht bei eigener Aufnahme, wenn der Name des Uploadenden automatisch hinterlegt wird. + +Besser wäre: + +**Urheber / Fotograf / Rechteinhaber** + +Denn nicht immer ist der Fotograf auch der Rechteinhaber. Bei Agenturen oder Unternehmen können die Rechte woanders liegen. + +## **4. „Copyright / Quelle“ klarer benennen** + +Das Feld „Copyright / Quelle“ ist etwas gemischt. Ich würde es aufteilen oder klarer formulieren: + +- **Copyright-Hinweis / Bildnachweis** +- **Quelle des Bildes** +- **Lizenz- oder Nachweis-URL** + +Beispiel: + +**Bildnachweis, wie er angezeigt werden soll** +`Foto: Max Mustermann / Beispiel GmbH` + +**Quelle / Fundstelle** +`https://...` + +**Lizenz-URL / Nachweis-URL** +`https://creativecommons.org/licenses/by/4.0/` oder Link zur Stocklizenz / Presseseite + +So vermeidest du, dass jemand nur „Internet“ oder „Google“ einträgt. + +## **5. Datei-Upload um Pflicht-Hinweise ergänzen** + +Beim Upload würde ich neben Dateityp und Größe noch einen kurzen Warnhinweis ergänzen: + +Bitte laden Sie nur Bilder hoch, für die Sie die erforderlichen Nutzungsrechte besitzen. Bilder aus Google, Social Media, Messenger-Gruppen oder fremden Websites dürfen nicht ohne ausdrückliche Erlaubnis verwendet werden. + +Das ist sehr hilfreich, weil genau dort viele Fehler passieren. + +## **6. Personenrechte besser abfragen** + +Dein Feld „Einwilligung abgebildeter Personen liegt vor“ ist gut, aber ich würde es differenzierter machen. + +Statt nur einem Schalter: + +**Sind Personen auf dem Bild erkennbar?** + +- Nein +- Ja, und die Einwilligung liegt vor +- Ja, aber es handelt sich um eine öffentliche Veranstaltung / redaktionelle Berichterstattung +- Unsicher + +Wenn „Ja“ oder „Unsicher“ gewählt wird, kannst du einen Hinweis anzeigen: + +Bei erkennbaren Personen können zusätzlich Persönlichkeits- oder Datenschutzrechte betroffen sein. Bitte stellen Sie sicher, dass eine Veröffentlichung zulässig ist. + +Der aktuelle Toggle „Einwilligung liegt vor“ ist gut, aber er setzt voraus, dass der Nutzer selbst erkennt, ob Personenrechte relevant sind. + +## **7. Property Rights / Marken / Kunstwerke ergänzen** + +Neben Personen sind auch diese Fälle kritisch: + +- Logos und Marken +- Kunstwerke +- private Innenräume +- Gebäude, Architektur, Museen +- Fahrzeuge mit Kennzeichen +- Veranstaltungsplakate oder Screenshots + +Ich würde daher ergänzen: + +**Enthält das Bild erkennbare Marken, Kunstwerke, geschützte Werke oder private Orte?** + +- Nein +- Ja, Rechte/Nutzung sind geklärt +- Unsicher + +Das schützt besonders bei PR-, Event- und Pressebildern. + +## **8. Rechtebestätigung präziser formulieren** + +Aktuell: + +Ich bestätige, dass ich zur Nutzung dieses Bildes berechtigt bin und alle Rechte geklärt sind. + +Ich würde es ausführlicher und rechtlich klarer machen: + +Ich bestätige, dass ich über die erforderlichen Rechte zur Veröffentlichung dieses Bildes verfüge. Dies umfasst insbesondere Urheberrechte, Nutzungsrechte, Persönlichkeitsrechte abgebildeter Personen sowie gegebenenfalls Marken-, Eigentums- oder sonstige Rechte Dritter. Ich bin berechtigt, das Bild auf diesem Presseportal veröffentlichen zu lassen. + +Optional zusätzlich: + +Mir ist bewusst, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin. + +Je nach Portal kannst du das etwas freundlicher formulieren, aber inhaltlich sollte es klar sein. + +## **9. Lizenznachweis als Datei-Upload ermöglichen** + +Sehr sinnvoll wäre ein optionaler Upload: + +**Nachweis / Freigabe hochladen** + +Zum Beispiel: + +- Lizenzbestätigung +- E-Mail-Freigabe +- Model Release +- Vertrag +- Screenshot der Lizenzseite +- Pressefreigabe + +Das muss nicht öffentlich sichtbar sein, aber intern gespeichert werden. + +## **10. Sichtbarer Bildnachweis im Frontend** + +Ich würde im Formular klar anzeigen: + +**Dieser Bildnachweis wird öffentlich angezeigt:** +`Foto: Max Mustermann / Beispiel GmbH` + +Viele Nutzer verstehen besser, was sie eintragen müssen, wenn sie sehen, wo es später erscheint. + +## **11. Meine empfohlene Struktur** + +So würde ich das Formular aufbauen: + +1. **Bild hochladen** +2. **Titel / Alt-Text** +3. **Bildnachweis, öffentlich sichtbar** +4. **Urheber / Fotograf / Rechteinhaber** +5. **Lizenztyp** +6. **Lizenzdetails je nach Auswahl** +7. **Quelle / Lizenz-URL** +8. **Personen auf dem Bild?** +9. **Marken, Kunstwerke oder private Orte sichtbar?** +10. **Nachweis/Freigabe hochladen** +11. **Rechte bestätigen** + +Zusätzlich würde ich **Alt-Text** ergänzen. Nicht primär wegen Recht, sondern wegen Barrierefreiheit und SEO. + +## **Konkrete Anpassung deiner Felder** + +Aus deinem Screenshot würde ich ändern: + +**„Copyright / Quelle“**→ **„Öffentlicher Bildnachweis“** + +**„Urheber / Fotograf“**→ **„Urheber / Fotograf / Rechteinhaber“** + +**„Lizenz-URL optional“**→ **„Quelle oder Lizenznachweis-URL“** + +**„Einwilligung abgebildeter Personen liegt vor“** +→ ersetzen durch Auswahl: +**„Sind erkennbare Personen abgebildet?“** + +**„Bildrechte bestätigt“** +→ Pflicht-Checkbox mit ausführlicher Bestätigung. + +## **Besonders wichtig** + +Ich würde **„Sonstiges“ nie ohne Pflicht-Freitext erlauben**. Wenn jemand „Sonstiges“ wählt, sollte zwingend erklärt werden müssen, warum die Nutzung erlaubt ist. + +Außerdem würde ich bei riskanten Angaben wie **CC-NC**, **CC-ND**, **Unsicher**, **Sonstiges** oder fehlender Lizenz-URL entweder eine Warnung anzeigen oder eine manuelle Prüfung durch die Redaktion auslösen. \ No newline at end of file diff --git a/docs/user-admin/Presseportal – Konzept für Relaunch.md b/docs/user-admin/Presseportal – Konzept für Relaunch.md new file mode 100644 index 0000000..46867f7 --- /dev/null +++ b/docs/user-admin/Presseportal – Konzept für Relaunch.md @@ -0,0 +1,1157 @@ + + + +> **Stand der Doku**: 11.06.2026 — dieses Konzept beschreibt den Zielzustand +> der Plattform. Umgesetzt sind inzwischen die KI-Prüfung (§1, §15.1/15.2) +> und Bilder/Lizenzen (§2); offen bleiben externe Meldungen, Magic-Link-Flow, +> Korrektur-Hinweise und Trust-Score. Welcher Teil in welchem Zustand ist, +> steht jeweils in einer **„IST-Stand"-Box** am Anfang des Abschnitts. +> +> **Für Tarife/Credits (§8–10) gilt das +> [`Decision-Update Preisstruktur & Veröffentlichungs-Flow`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md).** +> +> Aktueller Code-vs-Konzept-Abgleich: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](../STATUS-ABGLEICH-USER-PANEL.md). + +--- + +## 1. KI-Freigabe-Workflow für Pressemitteilungen + +> **IST-Stand 11.06.2026**: Der Drei-Stufen-Workflow ist **umgesetzt** +> (Detail-Doku: `Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`, +> Phasen 0–5): +> +> - Jede Einreichung (Formular + API) laeuft durch den Blacklist-Hard-Filter +> und wird anschliessend asynchron KI-klassifiziert (Rot/Gelb/Gruen, +> OpenAI-Treiber mit deterministischem Fallback, Queue `classification`). +> - Routing: Rot → `rejected` + Mail mit Begruendung, Gelb → manuelle +> Admin-Queue, Gruen → Auto-Publish (sofort oder zum geplanten Termin). +> - Jede Entscheidung wird in `ki_audits` protokolliert (Provider, Modell, +> Begruendung, Raw-Response); Re-Klassifikation bei Titel-/Text-Aenderung. +> - Zusaetzlich umgesetzt: Content-Score 0–100 → Stufe (§15.2). +> +> Offen: Trust-Score (§15.3), Live-Anzeige des Ergebnisses ohne Reload, +> DSGVO-Aufbewahrungsregel fuer `raw_response`. + +### Ziel + +Automatisierte Vorprüfung jeder neu eingereichten Pressemitteilung, um direkte Veröffentlichung problematischer Inhalte zu verhindern. + +### Editor + +> **IST-Stand 21.05.2026**: Der Editor ist `flux:editor` mit Absaetzen, +> fett, kursiv, Listen, Zitat, Links und Headings. Das ist bewusst etwas +> mehr als das urspruenglich geplante „nur fett + kursiv", weil +> Pressemitteilungen in der Praxis Zitate, Aufzaehlungen und gelegentlich +> Zwischenueberschriften brauchen. Der Inhalt wird beim Speichern +> serverseitig durch HTMLPurifier (`mews/purifier`, gekapselt in +> `App\Services\PressRelease\PressReleaseHtmlSanitizer`) bereinigt — alles +> ausserhalb der erlaubten Tag-Liste wird entfernt. + +- Einfacher Texteditor mit Absätzen +- Formatierungen: nur **fett** und _kursiv_ +- Keine weiteren Formatierungsoptionen + +### Klassifikation durch KI + +KI antwortet strukturiert (JSON) mit Score und Kategorien: + +- Werbung statt Pressemitteilung +- Beleidigend / diskriminierend +- Rechtlich heikel +- Spam-Muster +- Unseriöse Versprechen + +### Drei-Stufen-Ergebnis + +|Status|Behandlung|User sieht| +|---|---|---| +|**Grün**|Direkte Veröffentlichung (optional 5–10 Min Verzögerung)|Sofort live| +|**Gelb**|Manuelle Review-Queue|Status „in Prüfung"| +|**Rot**|Zurück an User mit Begründung|Möglichkeit zur Überarbeitung| + +### Logging + +Jede Prüfung wird vollständig geloggt: Prompt, KI-Antwort, Score, Zeitstempel, User-ID. Basis für spätere Prompt-Optimierung und Nachvollziehbarkeit. + +### Trust-Score (mittelfristig) + +Pro User wird ein Vertrauenslevel aufgebaut. Der Score kann im Admin Backend eingesehen und manuell justiert werden, damit Sonderfälle wie Distribution-Partner, Bestandskunden oder auffällige Accounts gezielt gesteuert werden können. + +Da aktuell Pressemitteilungen ohne Freigabe veröffentlicht werden, sollte die Einführung schrittweise erfolgen: + +- Startwert pro User aus Historie ableiten: Anzahl PMs, Ablehnungen, Beschwerden, manuelle Eingriffe. +- Neue/unbekannte User konservativer behandeln. +- Verlässliche User können bei grünem KI-Ergebnis schneller oder automatisch veröffentlicht werden. +- Admins können Trust-Score und Auto-Publishing-Status überschreiben. + +Beispiel: Nach z. B. 50 problemfrei veröffentlichten PMs wird die Grün-Schwelle automatisch gelockert. Reduziert spätere Moderationslast. + +### Aufbewahrung und Zugriff + +KI-Prüfungen müssen nachvollziehbar sein, dürfen aber nicht unbegrenzt und ungeschützt wachsen: + +- KI-Audits mit Prompt, Antwort, Score und Klassifikation speichern, aber mit definierter Aufbewahrungsfrist. +- Personenbezogene Daten und potenziell rechtswidrige Inhalte möglichst minimieren oder redigieren. +- Zugriff nur für Admins mit passender Berechtigung. +- Fristen im Admin Backend konfigurierbar machen, mindestens getrennt nach normalen Checks, roten Treffern und juristischen Fällen. + +--- + +## 2. Bilder & Lizenzen + +> **IST-Stand 11.06.2026**: Lizenz-/Rechteerfassung und Titelbild sind +> umgesetzt (Phase 8F–8H + Umbau 10./11.06., Detail-Doku: +> `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`, +> Fachvorgabe: `Lizenztyp Bildupload.md`): +> +> - Ein **Titelbild pro PM** (Cover-Variante 1280×580, Original wird nach +> Verarbeitung geloescht) oder SVG-Platzhalter aus dem zentralen Set +> (`App\Enums\PressReleasePlaceholder`, `PressReleaseCoverImage`-Resolver). +> - Vollstaendiges Rechteformular: Urheber, 7 Lizenztypen, Lizenzdetails, +> Lizenz-/Quell-URL, Personen- und Sachrechte-Status, interne Notizen, +> Rechte-Bestaetigung; bedingte Pflichtfelder + Risikohinweise. +> - Nur Quelle „Eigenes Bild hochladen". **Stock und KI sind nicht +> angebunden** (weiterhin offen, ebenso der KI-Wasserzeichen-Check). + +### Upload-Workflow + +Beim Klick „Bild hinzufügen" wählt der User die Quelle: + +1. **Eigenes Bild hochladen** +2. **Aus Bilddatenbank wählen** (Stock (kostenpflichtig?)) +3. **Mit KI generieren** (kostenpflichtig) + +### Eigenes Bild – Pflichtfelder + +Ohne Eingabe kein Speichern, kein Veröffentlichen: + +- **Urheber/Fotograf** (Freitext) +- **Lizenztyp** (Dropdown): + - Eigene Aufnahme + - CC-Lizenz + - Kommerzielle Lizenz erworben + - Einwilligung des Urhebers + - Sonstiges +- **Quelle/Lizenz-URL** (Pflicht bei CC und kommerziellen Lizenzen) +- **Bei abgebildeten Personen**: Checkbox „Einwilligung der abgebildeten Personen liegt vor" +- **Bestätigungs-Checkbox** (nicht vorausgewählt): + +> _„Ich bestätige, dass ich die erforderlichen Nutzungsrechte an diesem Bild besitze und stelle [Plattform] von sämtlichen Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung resultieren."_ + +### Optionaler KI-Check beim Upload + +Erkennt Wasserzeichen, bekannte Stock-Muster, Logos. Wirft Warnung an Admin, blockiert nicht automatisch. + +### Stock-Integration (Free) + +- **Unsplash + Pexels API** kostenlos einbinden +- Lizenz/Quelle automatisch gespeichert +- User muss keine Lizenzdaten manuell eingeben + +### Stock-Integration (Premium) + +- Adobe Stock oder Shutterstock per API +- Kosten x € pro Bild, weitergegeben an User mit Aufschlag + +### KI-Bildgenerierung + +- API-Anbindung z. B. Flux, DALL·E 3, Stable Diffusion +- User gibt keinen Prompt ein, erhält 1-2 Vorschläge zur Auswahl (Hier wird die Pressemitteilung mehr oder weniger als prompt genommen und es werden Vorschläge gemacht, die der User über Auswahlfelder oder Auswahlmenüs Justieren kann) +- **Wichtig:** + - Lizenzhinweis transparent + - Metadaten/dezenter Hinweis „mit KI erstellt" (wird mit AI Act Pflicht) + - Keine Generierung realer Personen/Marken (Prompt-Filter vorschalten) + +### Datenmodell `images` (grob) + +``` +- source_type (own/stock/ai) +- author +- license_type +- license_url +- stock_provider +- stock_id +- ai_provider +- ai_prompt +- rights_confirmed_at +- uploaded_by +- persons_consent (bool) +``` + +### AGB-relevante Punkte + +- Freistellungsvereinbarung: User stellt Plattform von Ansprüchen Dritter frei +- AGB-Klausel sollte vom Anwalt geprüft werden +- Notice-and-Takedown-Prozess (DSA-pflichtig) + +--- + +## 3. Pressemitteilung melden (öffentlich, durch Dritte) + +> **IST-Stand 21.05.2026**: Noch nicht implementiert. Es gibt weder ein +> oeffentliches Melde-Formular noch ein internes Ticketsystem. Das Thema +> bleibt fuer Phase 2/3 (DSA-Pflicht). + +### Ziel + +DSA-konformer Notice-and-Action-Mechanismus für Beschwerden Dritter. + +### Ticketsystem + +**Feste Kategorien:** + +- Urheberrecht +- Persönlichkeitsrecht +- Falschaussage +- Beleidigend +- Spam +- Sonstiges + +**Pflichtfelder:** + +- Kategorie +- Begründung (Freitext) +- Kontakt-E-Mail des Melders + +**Auto-Empfangsbestätigung** an Melder. + +### Behandlung + +- KI-Triage bewertet Plausibilität, ordnet Priorität zu +- Bei plausibler Meldung (besonders Urheberrecht): PM sofort in **Quarantäne** +- Autor wird informiert, bekommt Frist zur Stellungnahme +- Finale Entscheidung durch Admin + +### Abgrenzung zum Änderungs-Flow + +Der „Melden"-Button ist für **Dritte** (kein Login nötig). Änderungen an eigenen PMs laufen über den autorisierten Magic-Link-Flow (siehe Abschnitt 6). + +--- + +## 4. Korrektur-Modell für veröffentlichte Pressemitteilungen + +> **IST-Stand 21.05.2026**: Korrektur- und Update-Hinweise sind noch nicht +> umgesetzt. Was es gibt: +> +> - **Status-Wechsel** ueber `PressReleaseService` (`draft → review → +> published | rejected | archived`). +> - Eine rudimentaere **Tombstone-Variante** in +> `PressReleaseService::deleteFromAdmin()` — beim Loeschen einer +> bereits veroeffentlichten PM wird der Inhalt durch einen neutralen +> Ersatztext ersetzt und der Status auf `archived` gesetzt, statt die +> Zeile zu loeschen. +> - Editierbare Korrektur-/Update-/Tombstone-Textvorlagen im Admin Backend +> gibt es noch nicht. +> +> Die hier beschriebenen Drei-Stufen-Sichtbarkeitsregeln sind Phase 2/3. + +### Grundprinzip + +Pressemitteilungen sind historische Dokumente, kein Blog. **Ändern eigentlich nie, ergänzen ja, löschen nur in Ausnahmen.** + +### Drei Sichtbarkeits-Stufen + +**Korrektur-Hinweis** (bei sachlichen Fehlern): + +> _„Korrektur vom 5.5.2026: Der ursprünglich genannte Umsatz von 5 Mio. € wurde auf 4,2 Mio. € berichtigt."_ + +Hinweis erscheint oben gut sichtbar, Originaltext wird angepasst, Versionierung dokumentiert alte Fassung. + +**Update-Hinweis** (bei substanziellen Ergänzungen): + +> _„Update vom 5.5.2026: Das Produkt ist seit dem 1.5. nicht mehr verfügbar."_ + +Originaltext bleibt unverändert, Update wird unten angehängt. + +**Anonymisierung** (bei Kontaktdaten/DSGVO): Persönliche Daten werden ohne sichtbaren Hinweis ersetzt. DSGVO-konform und sogar Pflicht. + +### Tombstone statt Hard Delete + +Bei „Löschung" wird die PM nicht physisch entfernt, sondern die Seite zeigt: + +> _„Diese Pressemitteilung wurde am 5.5.2026 auf Wunsch des Autors entfernt. Die URL bleibt zu Zitat- und Archivzwecken erhalten."_ + +Der Text ist nur ein Beispiel. Öffentlich sollte neutral formuliert werden, damit keine unnötigen juristischen oder reputativen Aussagen entstehen. Tombstone-Texte, Korrekturhinweise, Update-Hinweise und Standardbegründungen sollen im Admin Backend als Textvorlagen pflegbar sein. + +- Aus Übersichten/Suche raus +- `noindex`-Tag gesetzt +- Backlinks funktionieren weiter +- Keine 404-Fehler + +**Echte physische Löschung** nur bei juristischer Notwendigkeit (Gerichtsurteil, schwere Persönlichkeitsrechtsverletzung). + +### SEO-Konsequenzen + +- **301** nur bei bewusster URL-Änderung +- **410 Gone** signalisiert permanent weg, nimmt Linkjuice mit +- **Tombstone + noindex**: behält Backlink-Wert, aus Suche raus → meist beste Wahl +- **Korrekturhinweise** sind Trust-Signal für Google + +--- + +## 5. DSGVO-Position + +### Kernaussage + +Pressemitteilungen fallen unter **berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)** im Rahmen von **Meinungs- und Informationsfreiheit (Art. 85 DSGVO, Medienprivileg)**. + +→ **Komplettlöschung einer PM ist kein DSGVO-Anspruch.** + +### Was gelöscht/anonymisiert werden muss (auf Wunsch) + +- Vollständiger Name natürlicher Personen im Kontaktblock +- Direktdurchwahl, Mobilnummer, persönliche E-Mail +- Privatadresse +- Foto identifizierbarer Personen + +### Was bleiben darf + +- Firmenname, Firmenadresse, Hauptrufnummer +- Funktions-E-Mails (`presse@firma.de`, `info@firma.de`) +- Geschäftsführer im Pressetext mit Tätigkeitsbezug (z. B. Zitate) +- Historische Aussagen zur Firma + +### Standard-Anonymisierungs-Patterns + +- Name → entfernt oder „[Name auf Wunsch entfernt]" +- Telefon → entfernt +- E-Mail → durch Funktions-E-Mail ersetzt (`presse@firma.de`) +- Im Versionsverlauf: Grund „DSGVO-Anonymisierung Art. 17" dokumentiert, Inhalt nicht erneut gespeichert + +### Sonderfall Persönlichkeitsrecht + +Persönlichkeitsrechtsverletzungen (§§ 823, 1004 BGB analog) sind **kein** DSGVO-Thema, können aber echte Komplettlöschung rechtfertigen. Eigener Pfad mit manueller Sichtung. + +--- + +## 6. User-Flow: Änderung durch Pressekontakt + +> **IST-Stand 21.05.2026**: Der Magic-Link-Flow fuer Pressekontakte ist +> noch nicht implementiert. Was es gibt: +> +> - Die Tabelle `magic_links` existiert (gehoert aktuell an `users`). +> - Der `MagicLinkGenerator` wird intern fuer Vorschau-Links +> („Share-Link") aus dem Admin-Press-Release-Show genutzt. +> - Es gibt **keine** separate `press_release_access_requests`- oder +> `press_contact_access_tokens`-Tabelle. +> - Der Aenderungs-Wizard (Pfade A–G) ist nicht gebaut. +> +> Das Thema bleibt fuer Phase 2. + +Dieser Flow ist vom normalen User-Login getrennt. Er soll Firmen und hinterlegten Pressekontakten erlauben, eigene Pressemitteilungen über E-Mail-Verifikation zu bearbeiten oder Änderungen zu beantragen, ohne dass zwingend ein vollwertiger User-Account existiert. + +Technisch sollte dafür nicht die bestehende `magic_links`-Tabelle am `users`-Model missbraucht werden. Besser ist eine neue Request-/Token-Tabelle für Pressekontakt- und Firmenzugriffe, z. B. `press_release_access_requests` oder `press_contact_access_tokens`. Diese referenziert bestehende Daten (`press_releases`, `companies`, `contacts`) und bleibt getrennt von echten Login-Usern. + +Wichtig: Tabellenbegriffe bleiben am bestehenden Modell ausgerichtet. Es gibt weiterhin `contacts` und `press_release_contact`; keine parallele Tabelle `press_contacts`. + +### Phase 1 – Zugang & Authentifizierung + +**Schritt 1**: Auf jeder PM dezenter Link „Sie sind als Pressekontakt hinterlegt? Pressemitteilung verwalten →" + +**Schritt 2**: E-Mail-Eingabe + Captcha + +**Schritt 3**: System prüft die eingegebene E-Mail gegen die vorhandenen Daten: + +- `contacts.email` über `press_release_contact` für direkt hinterlegte Pressekontakte. +- `companies.email` der Firma, die der PM über `press_releases.company_id` zugeordnet ist. +- optional später weitere verifizierte Firmen-E-Mails, falls ein eigenes Modell dafür entsteht. + +**Identische Antwort** unabhängig vom Match (verhindert User-Enumeration): + +> _„Falls die angegebene E-Mail-Adresse für diese Pressemitteilung oder Firma berechtigt ist, haben wir Ihnen einen Link zur Verwaltung gesendet."_ + +**Schritt 4**: Bei Match: Token-Mail mit 30-Min-Token. Klick öffnet eine begrenzte Verwaltungssession für genau die berechtigten Pressemitteilungen/Firmeninhalte, keine normale User-Session. + +### Phase 2 – Dashboard + +Liste aller PMs, für die diese E-Mail berechtigt ist: + +- direkt als Pressekontakt über `contacts` + `press_release_contact` +- über die Firmen-E-Mail der zugeordneten `company` +- später optional über weitere verifizierte Firmen-E-Mails + +Pro Eintrag: + +- Titel, Datum, Status (veröffentlicht / depubliziert / in Bearbeitung) +- Button **„Änderung beantragen"** + +Optional: _„Permanenten Account anlegen"_ → Passwort vergeben, künftig direkter Login. + +### Phase 3 – Änderungs-Wizard + +Erste Frage: **„Worum geht es?"** + +``` +A) Tippfehler / Grammatik korrigieren +B) Pressekontakt-Daten aktualisieren +C) Inhaltliche Korrektur (sachlicher Fehler) +D) Update / Ergänzung (neue Information) +E) Persönliche Daten entfernen lassen (DSGVO) +F) Persönlichkeitsrechtsverletzung melden +G) Pressemitteilung depublizieren +``` + +#### Pfad A – Tippfehler (low friction) + +- Inline-Editor mit Diff-Anzeige +- KI prüft Diff: nur kosmetisch? + - Ja → übernommen, kein öffentlicher Hinweis + - Nein → automatische Umleitung zu Pfad C +- **Kostenfrei** + +#### Pfad B – Pressekontakt-Daten (low friction) + +- Formular mit aktuellen Daten +- Direkt übernommen, kein öffentlicher Hinweis +- Versionierung im Hintergrund +- **Kostenfrei** + +#### Pfad C – Inhaltliche Korrektur (medium friction) + +- Editor + Pflichtfeld „Was war falsch und was ist korrekt?" +- KI prüft: Korrektur (Zahl, Datum, Name) ok / Umschreibung der Aussage blockiert +- Vorschau zeigt PM mit Korrektur-Hinweis +- **Kostenpflichtig**, nach Zahlung sofort live + +#### Pfad D – Update / Ergänzung (medium friction) + +- Textfeld für Ergänzung +- Original bleibt vollständig unverändert +- Wird unten angehängt mit Datum +- KI-Check auf Spam/Werbe-Update +- **Kostenpflichtig**, Sofortveröffentlichung + +#### Pfad E – DSGVO-Anonymisierung (low friction, kostenfrei) + +Aufklärungs-Schritt zuerst: + +> _„Pressemitteilungen sind öffentliche journalistische Dokumente und können nicht aufgrund der DSGVO gelöscht werden (Art. 85 DSGVO, Medienprivileg). Auf Wunsch entfernen wir aber kostenfrei personenbezogene Daten."_ + +Checkbox-Auswahl: + +- ☐ Name aus Pressekontakt +- ☐ Direktdurchwahl/Mobilnummer +- ☐ Persönliche E-Mail-Adresse +- ☐ Andere personenbezogene Angabe (Freitext mit Quote/Stelle) + +KI-Check ob plausibel im DSGVO-Sinn (Firmendaten werden rausgefiltert). **Kostenfrei**, sofort umgesetzt. + +#### Pfad F – Persönlichkeitsrechtsverletzung (manuelle Sichtung) + +- Pflichtfelder: betroffene Stelle, Art der Verletzung, Begründung, ggf. Belege +- Geht in Review-Queue, KI gibt Vorklassifikation +- **Kostenfrei** (legitimes Anliegen darf nicht durch Gebühr blockiert werden) +- Outcomes: Anonymisierung, Anpassung, Tombstone, Ablehnung mit Begründung + +#### Pfad G – Depublizieren (high friction) + +**G.1 Aufklärungsseite:** + +> _„Pressemitteilungen sind öffentliche, archivierte Dokumente. Eine Depublizierung sollte gut überlegt sein. Die URL bleibt mit einem Hinweis erhalten, der Inhalt wird nicht mehr angezeigt. Diese Aktion ist nicht ohne Weiteres rückgängig zu machen."_ + +**G.2 Begründungspflicht** (Dropdown + Freitext, KI klassifiziert): + +- _„Veraltet"_ → Hinweis: „Veraltung ist kein Grund. Update (Pfad D)?" +- _„Falsch / peinlich"_ → Hinweis: „Stattdessen Korrektur (Pfad C)?" +- _„Firma existiert nicht mehr"_ → ok, weiter +- _„Anderer Grund"_ → Freitext, KI prüft + +**G.3 Kostenpflichtige Bestätigung** (höhere Gebühr als Korrektur) + +**G.4 Bedenkzeit (24–48 h):** + +- Nach Zahlung: Status „Depublizierung wird in 24h ausgeführt" +- Widerrufslink per E-Mail +- Im Dashboard sichtbar mit „Widerrufen"-Button + +**G.5 Ausführung**: Tombstone-Seite, `noindex`, aus Listen/Suche raus, URL bleibt + +### Friction-Übersicht + +|Pfad|Auth|KI-Check|Kosten|Wartezeit|Public Hint| +|---|---|---|---|---|---| +|A Tippfehler|Magic-Link|ja|–|–|nein| +|B Kontaktdaten|Magic-Link|–|–|–|nein| +|C Korrektur|Magic-Link|ja|ja|–|ja| +|D Update|Magic-Link|ja|ja|–|ja| +|E DSGVO|Magic-Link|leicht|–|–|nein| +|F Persönlichkeitsrecht|Magic-Link|Triage|–|manuell|je nach Outcome| +|G Depublizieren|Magic-Link|ja|ja|24–48 h|Tombstone| + +### Edge Cases + +- **Keine valide E-Mail im Pressekontakt** (alte connektar-PMs): Fallback „Verifikation per Domain-Inhaberschaft / Impressums-Match", manuelle Prüfung +- **E-Mail wurde geändert / Person verlässt Firma**: Manuelle Anfrage mit Bestätigung über `info@`-Adresse +- **Massenanträge**: Bulk-Aktion möglich, Friction wird **pro PM** angewendet + +### Missbrauchsschutz + +- Rate-Limit auf Magic-Link-Anfragen pro E-Mail/IP +- Cooldown nach Hard-Delete-Anfrage (24 h Wiederherstellung) +- Audit-Log mit IP/User-Agent jeder Edit-Aktion + +### Standard-Antwort an unautorisierte Lösch-Anfragen per Mail + +> _„Änderungen an Pressemitteilungen sind ausschließlich über das Selbstbedienungs-Portal möglich. Bitte besuchen Sie [URL der PM] und nutzen Sie den Link ‚Pressemitteilung verwalten'. Die Authentifizierung erfolgt über die in der PM hinterlegte Pressekontakt-E-Mail."_ + +--- + +## 7. Distribution-Partner (connektar.de) + +### Status + +Über 50 % des aktuellen PM-Volumens kommt über connektar via API. Aktuell kein klassischer Vertrag, nur AGB + Rechnung. Wichtig für Traffic während Relaunch-Phase, daher zunächst weiterlaufen lassen. + +### Behandlung + +- Eigene Account-Kategorie: `source: distribution_partner` (nicht öffentlich beworben) +- Kein Standard-Tier, sondern Custom-Vertrag (Wholesale-Account) +- API-Rate-Limits (z. B. max. 50/Stunde) zum Schutz vor Lastspitzen +- Strengere Moderations-Schwelle in der KI-Prüfung + +### Tracking + +Eigene Statistiken trennen: + +- Volumen, Engagement, Beschwerden, KI-Ablehnungsrate +- Datenbasis für Vertragsverlängerungen und Qualitäts-SLA + +### Risiko + +**Klumpenrisiko bei >50 % Anteil.** Beim nächsten Renewal Kündigungsrecht bei Qualitätsproblemen verankern. + +--- + +## 8. Preismodell – Tarife (überarbeitet) + +> **⚠️ Ueberschrieben (11.06.2026)**: Die Abschnitte 8, 9 und 10 sind durch +> das [`Decision-Update Preisstruktur & Veröffentlichungs-Flow`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) +> ersetzt. Wichtigste Aenderungen: Kontingente Pro 25 / Agency 60 (statt +> 60/150), Jahrespreis als „2 Monate gratis", Bonus-Credits aus der +> Tarif-Tabelle entfernt, Launch-Credits auf Extra-PM / Boost / +> PDF-Nachweis reduziert, Slot-Verbrauch erst bei Veroeffentlichung. +> Der folgende Text bleibt als urspruengliche Zielvorstellung erhalten. +> +> **IST-Stand 11.06.2026**: Das Tarif- und Credit-System ist noch nicht +> implementiert. Es gibt: +> +> - Eine Tabelle `user_payment_options` (mit Pivot zu `companies`). +> - Eine Tabelle `invoices` (aktuell + Legacy ueber `legacy_invoices`). +> - Keine Tarif-Stufen, keine Stripe-Anbindung, kein Auto-Refill. +> - Den Phase-8-**Quota-Stub** auf `users` (`press_release_quota`, +> `press_release_quota_used_this_month`) samt Kontingent-Anzeige im +> Veroeffentlichungs-Modal — zaehlt aktuell beim Einreichen, laut +> Decision-Update kuenftig bei Veroeffentlichung. + +### Grundlogik + +Alle Tarife enthalten ein Kontingent an Pressemitteilungen sowie monatlich ausgeschüttete Bonus-Credits für Tools und Add-ons. Bonus-Credits aus Abos verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig. So bleibt das Abo aktivierungsstark, ohne dass der Nutzer eigenes Geld verliert. + +### Tier-Struktur + +|Tier|Preis|PMs|Bonus-Credits/Mo.|Effektiver PM-Preis|Besonderheiten| +|---|---|---|---|---|---| +|**Einzel**|19 € / Stück|1|4 (verfallend nach 30 T)|19,00 €|Pay-as-you-go| +|**Starter**|19 €/Mo. (190 €/Jahr)|3|12|6,30 €|Free-Stock, KI-Quality-Check| +|**Business**|49 €/Mo. (490 €/Jahr)|10|30|4,90 €|Erweiterte Statistiken, optionaler Newsroom| +|**Pro**|99 €/Mo. (990 €/Jahr)|unbegrenzt (Fair Use)|60|< 2 €|Eigener Newsroom, Priority, volles Statistik-Dashboard| +|**Agency**|199 €/Mo. (1.990 €/Jahr)|unbegrenzt für 5 Marken|120|< 1 €|Multi-Redakteur-Workflow, API-Zugang, je weitere Marke 29 €/Mo.| + +Jahrespreise mit ca. 17 % Rabatt eingebaut. Fair Use im Pro-Tarif: Soft-Cap 50 PMs/Monat. + +### Mehrwerte im Vergleich + +|Feature|Einzel|Starter|Business|Pro|Agency| +|---|---|---|---|---|---| +|Pressemitteilungen|1|3/Mo.|10/Mo.|unbegr.|unbegr. (5 Marken)| +|Bonus-Credits|4 einmalig|12/Mo.|30/Mo.|60/Mo.|120/Mo.| +|Free-Stock-Bilder|✓|✓|✓|✓|✓| +|KI-Quality-Check|✓|✓|✓|✓|✓| +|Erweiterte Statistiken|–|–|✓|✓|✓| +|Eigener Newsroom|–|–|optional|inkl.|inkl.| +|Priority-Support|–|–|–|✓|✓| +|Multi-Redakteur-Workflow|–|–|–|–|✓| +|API-Zugang|–|–|–|–|✓| + +### Kommunikation + +Die inkludierten Bonus-Credits sind Teil des Pakets, nicht zusätzliche Kosten. Reicht das Kontingent nicht (z. B. weil mehrere PMs mit aufwändigem Tooling veröffentlicht werden), kauft der Nutzer Credits nach – diese bleiben 24 Monate erhalten und schaffen langfristige Bindung an die Plattform. + +### Bestandskunden + +Aktive Jahresabos behalten Preis bis zum nächsten Verlängerungstermin. Loyalty-Bonus 10–20 % im ersten Verlängerungsjahr. Downgrade-Pfad anbieten. + +### Einstiegsstrategie + +In der Anfangsphase (erste 6–12 Monate nach Relaunch) bewusst günstiger einsteigen, um User-Base aufzubauen. Preise sind kalkuliert mit Spielraum für spätere Anpassung. Wichtig: Bestandskunden behalten ihre Konditionen. + +--- + +## 9. Credit-System (überarbeitet) + +### Grundregel + +**1 Credit = 1 €** als Listenpreis-Anker. Alle Service-Preise werden in ganzen Credits ausgewiesen. Wer größere Pakete kauft, zahlt effektiv weniger pro Credit (Volumenrabatt), aber der Listenpreis bleibt stabil. So entfällt jede Kopfrechen-Übung im UI. + +### Credit-Pakete + +|Paket|Credits|Preis|Effektiv pro Credit|Ersparnis| +|---|---|---|---|---| +|Test|10|10 €|1,00 €|–| +|Standard|50|45 €|0,90 €|10 %| +|Plus|150|120 €|0,80 €|20 %| +|Pro|500|375 €|0,75 €|25 %| +|Business|1.500|1.050 €|0,70 €|30 %| + +Ganzzahlige Beträge, keine Bruchteile im UI. Intern kann auf Cent-Ebene abgerechnet werden, aber nach außen sieht der Nutzer nur ganze Credits. + +### Auto-Refill + +Standardmäßig nach erstem Kauf aktiviert (mit Opt-Out): + +- Trigger: bei < 10 Credits Restguthaben + +- Aufladung: zuletzt gekauftes Paket (Default Standard, 50 Credits) + +- Eindeutige Bestätigungs-Mail nach jeder automatischen Aufladung + + +### Gültigkeit + +- Gekaufte Credits: 24 Monate ab Kauf + +- Bonus-Credits aus Abos: monatlich verfallend + +- Willkommens-Bonus (5 Credits einmalig bei Account-Anlage): 90 Tage + + +### Mini-Checkout (kontextuell) + +1. User klickt z. B. „KI-Bild generieren" + +2. Modal: _„Kostet 4 Credits. Du hast 2 Credits."_ + +3. Optionen: + + - „Schnell aufladen: Standard-Paket (50 Credits, 45 €)" – 1-Klick mit Saved Payment Method + + - „Anderes Paket wählen" + + - „Abbrechen" + +4. Nach Aufladung wird Aktion automatisch ausgeführt + + +### Erstkauf + +Stripe Checkout mit `setup_future_usage` für Saved Payment Method. Danach 1-Klick-Aufladung. + +### Dashboard + +- Credit-Stand oben rechts immer sichtbar + +- Trennung sichtbar: Bonus-Credits (verfallend) vs. gekaufte Credits (24 Monate) + +- Verlauf einsehbar (was wofür verbraucht) + +- Rechnungs-PDFs für jede Aufladung + + +### Buchhaltung & Recht + +- Credits = Vorauszahlung, bilanziell als Verbindlichkeit + +- MwSt-Behandlung mit Steuerberater abstimmen (Kauf vs. Verbrauch) + +- Verfall in AGB sauber dokumentieren + +- Keine Auszahlung in Geld (sonst PSD2-Lizenzthema) + +- EU-Auslandskunden: Reverse-Charge bei B2B mit USt-ID + + +--- + +## 10. Preisliste in Credits (überarbeitet) + +Alle Preise in ganzen Credits (1 Credit = 1 €). Anker-Werte für die Startphase, iterativ anpassbar. + +### Veröffentlichung + +|Service|Credits| +|---|---| +|Standard-PM (Pay-as-you-go)|19| +|PM-Korrektur (Pfad C)|8| +|PM-Update (Pfad D)|4 _(im ersten Jahr ggf. kostenlos)_| +|Depublizierung (Pfad G)|19–25| + +### Bilder + +|Service|Credits| +|---|---| +|Free-Stock (Unsplash, Pexels)|0| +|Premium-Stock (Adobe, Shutterstock)|8| +|KI-Bild generieren|4| +|KI-Bild Re-Generation|2| + +### KI-Textservices + +|Service|Credits| +|---|---| +|Quality-Check (Stil/Pressestil)|3| +|Lektorat|8| +|Pressetext-Optimierung (Headlines, SEO)|15| +|Headline-Booster (nur Headlines)|5| +|PM aus Stichworten generieren|25| +|Übersetzung (DE↔EN)|12| + +### Platzierungen + +|Service|Credits| +|---|---| +|Highlight Kategorie (3 Tage)|15| +|Highlight Kategorie (7 Tage)|30| +|Startseite-Highlight (24 h)|39| +|Startseite-Highlight (3 Tage)|89| +|Top-Slot Startseite (24 h)|119| +|Newsletter-Erwähnung|59| +|Social-Share (offizieller Kanal)|25| + +Voraussetzung für alle Platzierungen: Mindest-Content-Score erreicht (siehe Abschnitt „Boost-Eligibilität"). + +### Distribution + +|Service|Credits| +|---|---| +|PDF-Export mit Branding|2| +|Social-Snippet-Generierung|3| +|Verteiler-Versand (klein, branchenspezifisch)|39| +|Verteiler-Versand (mittel)|99| +|Verteiler-Versand (groß, branchenübergreifend)|199| + +### Account / Profil + +|Service|Credits| +|---|---| +|Verifiziertes Firmenprofil (einmalig)|79| +|Custom Subdomain (pro Jahr)|49| +|Erweiterte Statistiken (pro Monat)|15| + +### Goodies (kostenlos, fördern Aktivität) + +- PM-Updates kostenfrei im ersten Jahr (besseres Archiv) + +- 3 Free-Stock-Bilder pro PM + +- Erster KI-Quality-Check pro PM kostenfrei + +- 5 Credits Willkommens-Bonus bei Account-Anlage (90 Tage gültig) + +- Headline-Vorschlag (1 Variante) kostenfrei pro PM + + +--- + +--- + + + +--- + +## 11. Weitere Monetarisierungs-Ideen + +Mittelfristig prüfen, nicht im ersten Release: + +- **Job-Anzeigen / Stellenausschreibungen**: €49–99 pro Anzeige oder Jahres-Pauschale +- **Branchen-Verzeichnis (Sponsored Listings)**: prominente Listung in Rubriken, €19–49/Monat +- **Whitepaper-/Studien-Hosting**: Landingpage + Lead-Capture, €99–299/Monat +- **Event-Ankündigungen**: eigene Rubrik (Webinare, Messen, Launches), €29–79/Event +- **API-Zugang für Distribution-Partner**: skaliertes connektar-Modell, €499–999/Monat mit Limits +- **SEO-Backlink-Aufwertung**: heikel, nicht offensiv kommunizieren, €99–299 +- **White-Label-PM-Verteilung**: B2B-Modell für andere Portale +- **Affiliate-Werbung in PMs**: Tracking-Links, Umsatzbeteiligung – experimentell + +--- + +## 12. Build-Reihenfolge (Empfehlung) + +1. **Backend-Migration zu Laravel** (in Umsetzung) +2. **Magic-Link-Auth + „Meine Pressemitteilungen"** (Dashboard) +3. **Self-Service-Buttons** für Top-3-Fälle (Kontaktdaten, Depublizieren, Link) +4. **Versionierung** im Datenmodell (`press_release_revisions`) +5. **KI-Freigabe-Workflow** für neue PMs (3-Stufen-Klassifikation) +6. **Bild-Modul** mit Pflichtfeldern + Free-Stock-Integration +7. **Credit-System** als Datenmodell (Konto, Transaktionen, Verfall) +8. **Stripe-Integration** mit Saved Payment Methods + Auto-Refill +9. **Erste Add-ons** anbinden: Highlight, KI-Bild, KI-Lektorat +10. **KI-Triage** für Änderungs-Wizard (Pfad A vs. C, Pfad G) +11. **Melden-Button** mit DSA-konformem Ticketsystem +12. **Premium-Stock + KI-Bildgenerierung** +13. **Tombstone-Logik** + SEO-Setup (`noindex`, 410 nur wo nötig) +14. **Tarif-Integration** mit Bonus-Credits +15. Schrittweise weitere Services anbinden, datengetrieben + +--- + +## 13. Datenmodell-Skizze (relevante Tabellen) + +``` +press_releases + - id, title, body, published_at, status, tombstone_at, ... + +press_release_revisions + - id, press_release_id, type (correction/update/anonymization) + - old_content, new_content, reason, created_by, created_at + +contacts + - bestehende Tabelle: id, company_id, name fields, email, phone, ... + +press_release_contact + - bestehender Pivot: press_release_id, contact_id + +press_release_access_requests + - neue Tabelle für Phase 2: id, press_release_id, company_id, contact_id nullable + - requester_email, token_hash, purpose, expires_at, consumed_at + - scope (single_press_release/company_press_releases) + +change_requests + - neue Tabelle für Phase 2: id, press_release_id, requester_email + - path (A-G), status, ki_classification, ki_score + - payment_id, scheduled_for, executed_at + +reports (Melden-Button) + - id, press_release_id, reporter_email, category + - reason, status, ki_triage, created_at + +images + - id, source_type, author, license_type, license_url + - stock_provider, stock_id, ai_provider, ai_prompt + - rights_confirmed_at, persons_consent, uploaded_by + +credit_accounts + - id, user_id, balance_cent_credits, auto_refill_enabled + - auto_refill_package_id + +credit_transactions + - id, account_id, amount_cent_credits (signed) + - type (purchase/spend/bonus/expiry) + - reference_type, reference_id (z. B. image_id, press_release_id) + - expires_at, created_at + +ki_audits + - id, reference_type, reference_id, prompt, response + - score, classification, created_at + +admin_text_templates + - id, key, locale, title, body, is_active + - für Tombstone, Korrekturhinweise, Update-Hinweise, Ablehnungen, Standardmails + +retention_policies + - id, key, retention_days, description, is_active + - konfigurierbare Aufbewahrungsfristen für KI-Audits, Access-Requests, Change-Requests, API-Logs +``` + +--- + +## 14. Offene Punkte / nächste Entscheidungen + +- Konkrete Preise für Korrektur (Pfad C), Update (Pfad D), Depublizierung (Pfad G) finalisieren +- Stripe-Webhook-Architektur für Credit-Transaktionen +- AGB-Anpassung durch Anwalt (Lizenzklausel, Credits, DSA, DSGVO) +- KI-Prompt für Diff-Klassifikation und Veröffentlichungs-Triage final ausarbeiten +- Migrations-Plan für Bestands-PMs ohne Bilder/Lizenzangaben +- Newsletter-Aufbau (Voraussetzung für Newsletter-Add-on) +- Trust-Score-Schwellenwerte definieren +- Aufbewahrungsfristen für KI-Audits, Token-Requests, Change-Requests und API-Logs definieren +- Admin-pflegbare Textvorlagen für Tombstones, Korrekturen, Updates, Ablehnungen und Standardmails konzipieren +- Pressekontakt-/Firmen-E-Mail-Zugriff technisch als getrennte Access-Request-Logik modellieren, nicht als normaler User-Login + +## Abschnitt 15: Score-Architektur + +> **IST-Stand 11.06.2026**: §15.1 (Klassifikations-Score Rot/Gelb/Grün) und +> §15.2 (Content-Score 0–100 → Stufe) sind **umgesetzt** — siehe +> `Entwicklungsplan KI-Pruefung und Veroeffentlichung.md` (Phasen 2–5). +> §15.3 (Trust-Score) ist weiterhin offen (Phase 3). + +Die Plattform arbeitet mit drei voneinander unabhängigen Scores. Sie haben unterschiedliche Funktionen, werden unterschiedlich berechnet und an unterschiedlichen Stellen wirksam. Die Trennung ist zentral, weil sie unterschiedliche Datenmodelle und Update-Logiken betrifft. + +### 15.1 Klassifikations-Score (Eintritts-Filter) + +**Funktion:** Entscheidet, ob eine Pressemitteilung überhaupt veröffentlicht wird. + +**Bereich:** Grün / Gelb / Rot (kategorial) + +**Faktoren:** + +- Werbung statt Pressemitteilung + +- Beleidigend / diskriminierend + +- Rechtlich heikel + +- Spam-Muster + +- Unseriöse Versprechen + + +**Auswirkung:** + +- Grün: Direkte Veröffentlichung (optional 5–10 Min Verzögerung) + +- Gelb: Manuelle Review-Queue + +- Rot: Zurück an User mit Begründung + + +**Aktualisierung:** Einmalig bei Einreichung. Bei Änderung der PM (Pfad C/D) wird neu klassifiziert. + +**Speicherung:** `press_releases.classification` plus vollständiges Audit-Log in `ki_audits`. + +### 15.2 Content-Score (Qualitäts-Indikator) + +**Funktion:** Misst die handwerkliche Qualität einer Pressemitteilung. Bestimmt organische Sichtbarkeit und Boost-Berechtigung. + +**Bereich:** 0–100 Punkte + +**Faktoren (Vorschlag, iterativ verfeinerbar):** + +|Kategorie|Gewichtung|Was zählt| +|---|---|---| +|Pressestil|20 %|Tonalität (informativ vs. werblich), passive vs. aktive Konstruktion, Zitate vorhanden| +|Struktur|15 %|Lead-Absatz vorhanden, sinnvolle Absatzstruktur, Pyramidaler Aufbau| +|Lesbarkeit|10 %|Flesch-Index für Deutsch, Satzlängen, Fachsprache angemessen| +|Vollständigkeit|15 %|Pressekontakt, Unternehmensinfo, Datum, Branche, Region| +|Bildmaterial|10 %|Mindestens 1 Bild, Auflösung, Alt-Text, Bildunterschrift| +|Quellen / Belege|10 %|Verlinkungen, Studien-Referenzen, Datenquellen| +|Headline-Stärke|10 %|Länge, Keyword-Relevanz, Klarheit| +|Originalität|10 %|Kein Boilerplate, kein Duplicate-Content, individueller Ton| + +**Auswirkung:** + +- **Organische Sichtbarkeit:** Listing-Position, Top-Story-Kandidat, Newsletter-Aufnahme, Trending in Branche + +- **Boost-Berechtigung:** Schwellenwerte für kostenpflichtige Slots (siehe Abschnitt 16) + +- **User-Feedback:** Sichtbar im Editor-Dashboard mit konkreten Verbesserungsvorschlägen + + +**Aktualisierung:** Bei Einreichung berechnet, bei jeder Änderung der PM neu berechnet. History pro PM in `content_scores`. + +**Speicherung:** `press_releases.content_score` (aktueller Wert), `content_scores` (History mit Faktor-Breakdown). + +### 15.3 Trust-Score (Reputations-Indikator) + +**Funktion:** Bewertet die Zuverlässigkeit eines Publishers über Zeit. Reduziert Moderationslast und kann öffentliche Anerkennung bringen. + +**Bereich:** 0–100 oder Stufen (Bronze / Silber / Gold / Verifiziert) + +**Faktoren:** + +- Anzahl problemfrei veröffentlichter PMs + +- Durchschnittlicher Content-Score über alle PMs + +- Beschwerderate (Reports, Korrekturen, Depublizierungen) + +- Account-Alter + +- Verifikations-Status (verifiziertes Firmenprofil) + + +**Auswirkung:** + +- **Moderation:** Lockerung der KI-Freigabe-Schwelle (mehr „Grün" automatisch) + +- **Sichtbarkeit (optional):** öffentliches Verifizierungs-Badge auf Newsroom und PM-Seiten + +- **Bevorzugung in Branchen-Übersichten** bei gleichem Content-Score + +- **Bei Trust-Verlust:** Rückfall in strengere Moderation (auch nach Beschwerden, häufigen Korrekturen, Depublizierungen) + + +**Aktualisierung:** Rollierend, z. B. nächtlicher Cron-Job über die letzten 90 Tage Aktivität. + +**Speicherung:** `accounts.trust_score`, `accounts.trust_tier` (Bronze/Silber/Gold/Verifiziert), History in `trust_score_log`. + +### Offene Detail-Entscheidungen + +- Trust auf User- oder auf Firmen-Ebene? (Empfehlung: Firmen-Ebene, weil Mitarbeiter wechseln) + +- Trust-Verlust: ab welchen Schwellen? + +- Verifizierungs-Badge: nur über kostenpflichtigen Verifizierungs-Prozess oder auch durch Trust-Score erreichbar? + + +--- + +## NEU – Abschnitt 16: Boost-Eligibilität + +Die Verbindung zwischen Score-System und kostenpflichtigen Sichtbarkeits-Slots. Grundprinzip: **Schlechter Content kann nicht in den Top-Slot gekauft werden.** Das schützt die redaktionelle Glaubwürdigkeit der Plattform und schafft den Anreiz, in Qualität zu investieren. + +### Schwellenwerte je Slot-Typ + +|Slot|Klassifikation|Min. Content-Score| +|---|---|---| +|Highlight Kategorie|Grün|50| +|Startseite-Highlight (24h / 3 T)|Grün|65| +|Top-Slot Startseite|Grün|75| +|Newsletter-Erwähnung|Grün|70| +|Social-Share (offizieller Kanal)|Grün|70| +|Verteiler-Versand (extern)|Grün|80| + +PMs mit Klassifikation Gelb können nicht boostbar werden, auch nicht nach manueller Freigabe – sie bleiben in regulärer Sichtbarkeit. PMs mit Klassifikation Rot werden nicht veröffentlicht und sind damit irrelevant. + +### UI-Logik + +Wenn ein User einen Boost-Slot bucht, dessen Schwelle seine PM nicht erreicht, sieht er statt des Buchungsformulars: + +> _„Diese Pressemitteilung erreicht aktuell einen Content-Score von 60/100. Für den Top-Slot Startseite empfehlen wir mindestens 75 Punkte. So kannst du deinen Score verbessern:"_ +> +> _[Pressetext-Optimierung – 15 Credits → +15–20 Punkte]_ _[Headline-Booster – 5 Credits → +3–7 Punkte]_ _[Bild hinzufügen – 4 Credits → +5–10 Punkte]_ + +Nach Tool-Anwendung wird der Score neu berechnet, der Slot kann dann gebucht werden. + +### Effekt + +Drei gewollte Konsequenzen: + +1. **Plattform-Qualität bleibt hoch:** Premium-Slots zeigen nur qualitativ hochwertige Inhalte. + +2. **Tools werden indirekt verkauft:** Wer den Slot will, muss in Qualität investieren – entweder selbst oder über kostenpflichtige Tools. + +3. **Glaubwürdigkeit für Leser bleibt erhalten:** Leser und Journalisten erkennen schnell, dass sichtbar platzierte Inhalte tatsächlich relevant sind. + + +### Sonderfall: Editorial-Pick + +Unabhängig vom Boost-System kann die Redaktion (intern) PMs als „Empfehlung der Redaktion" hervorheben. Das ist ein redaktionelles Instrument, kein kommerzielles, und nicht buchbar. Wirkt als Vertrauensanker auf der Startseite. + +--- + +## NEU – Abschnitt 17: Tool-zu-Algorithmus-Loop + +Der strategische Kern der Monetarisierungslogik. Der Loop verbindet drei Plattform-Ziele in einem geschlossenen System: + +### Die drei Ziele + +1. **Plattform-Qualität:** Hohe durchschnittliche Inhaltsqualität, damit Leser, Journalisten und Mediaplaner die Plattform als seriös wahrnehmen. + +2. **Monetarisierung:** Umsatz aus Tools, Tarifen und Boost-Slots. + +3. **Anreiz für Publisher:** Sichtbar gute Platzierungen für gute Inhalte als motivierender Faktor. + + +### Der Loop + +Publisher schreibt PM +   ↓ +Content-Score wird berechnet (z. B. 55/100) +   ↓ +Publisher will Top-Slot buchen (Schwelle 75) +   ↓ +System empfiehlt: Pressetext-Optimierung (15 Credits) +   ↓ +Tool wird angewendet, Score steigt auf 78 +   ↓ +Top-Slot wird gebucht (119 Credits) +   ↓ +PM erscheint prominent auf Startseite +   ↓ +Hohe Reichweite, gute Statistiken +   ↓ +Publisher sieht Wert, kommt wieder +   ↓ +Plattform-Durchschnittsqualität steigt +   ↓ +Mehr Leser, mehr Wert für nächsten Boost-Käufer + +### Voraussetzungen für Funktionieren + +- **Tools müssen tatsächlich gut sein.** Wenn das KI-Lektorat schlechter ist als das, was der Publisher selbst zustande bringt, kollabiert der Loop. → Tool-Qualität ist Wettbewerbsvorteil, hier wird investiert. + +- **Score-Verbesserung muss spürbar und nachvollziehbar sein.** Der Publisher muss verstehen, was sein Tool-Einsatz konkret gebracht hat. → Score-Breakdown sichtbar, Vorher-Nachher-Vergleich. + +- **Reichweite muss real sein.** Ein gekaufter Top-Slot muss tatsächlich Reichweite bringen. → Leser-Seite (Newsletter, SEO, Social) muss aktiv aufgebaut werden. + +- **Boost-Schwellen dürfen nicht zu hoch sein.** Sonst wird der Loop frustrierend statt motivierend. → Schwellen iterativ kalibrieren auf Basis realer Score-Verteilung. + + +### Was das für den Build bedeutet + +- **Tools haben strategische Priorität.** KI-Lektorat, Pressetext-Optimierung, Headline-Booster sind nicht nur Add-ons, sondern das Herzstück der Wertschöpfung. + +- **Score-Anzeige muss früh implementiert werden.** Ohne sichtbaren Score kein Loop. + +- **Statistik-Dashboard ist Pflicht für mittlere Tarife.** Ohne sichtbare Reichweiten-Daten erkennen Publisher den Wert ihres Investments nicht. + + +--- + +## 18. Datenmodell-Skizze – Ergänzungen + +Zusätzlich zu den bestehenden Tabellen aus dem Hauptkonzept: + +content_scores + - id, press_release_id + - score (0-100), version (bei Neuberechnung) + - factors (JSON: pressestil, struktur, lesbarkeit, vollstaendigkeit, +             bildmaterial, quellen, headline, originalitaet) + - calculated_at, calculation_reason (initial/edit/tool_applied) +​ +placements + - id, press_release_id, account_id + - slot_type (kategorie_highlight, startseite_highlight, top_slot, +               newsletter, social_share, verteiler_klein/mittel/gross) + - starts_at, ends_at + - credits_spent + - status (scheduled, active, completed, cancelled) + - eligibility_check_passed (bool, snapshot bei Buchung) + - eligibility_score_snapshot (Content-Score zum Zeitpunkt der Buchung) + - created_at +​ +placement_inventory + - id, slot_type + - max_concurrent (z.B. 1 für Top-Slot, 3 für Startseite-Highlight) + - duration_options (JSON: [24h, 72h]) + - min_content_score (75) + - min_classification ('green') +​ +trust_score_log + - id, account_id + - score (0-100), tier (bronze/silber/gold/verifiziert) + - factors (JSON: pm_count, avg_content_score, complaints, +             account_age_days) + - calculated_at +​ +accounts (Ergänzungen) + - + trust_score (int, 0-100) + - + trust_tier (enum) + - + verified_business_profile (bool) + - + verified_at + +Wichtige Logiken: + +- **placement_inventory** definiert, wie viele Slots welcher Art parallel verfügbar sind. Bei Buchung wird geprüft: ist ein Slot für das gewünschte Zeitfenster frei? Wenn nicht: nächstmöglicher Termin anbieten oder ablehnen. + +- **eligibility_score_snapshot** auf Placement-Ebene: damit nachvollziehbar bleibt, mit welchem Score eine PM zum Buchungszeitpunkt qualifiziert war. Wenn der Score später sinkt (etwa durch Korrektur), bleibt der gebuchte Slot bestehen, aber bei Verlängerung wird neu geprüft. + +- **content_scores** mit Versionierung erlaubt nachträglich Auswertung: Welche Tools haben welchen Score-Effekt gehabt? Daten für Tool-Optimierung. + + +--- + +## Offene Punkte / nächste Entscheidungen (Update) + +Zusätzlich zu den bereits dokumentierten Punkten: + +- **Content-Score-Faktoren feinjustieren:** Welche Gewichtung passt für deutsche Pressemitteilungen? Iterativ kalibrieren mit echten Daten. + +- **Boost-Schwellen kalibrieren:** Erst nach 100–200 echten PMs sehen, wo die Score-Verteilung liegt. Schwellen ggf. anpassen. + +- **Trust-Score: User vs. Firma:** Empfehlung Firma, aber Detail-Logik bei Mitarbeiterwechsel klären. + +- **Tool-Qualität:** KI-Prompts für Lektorat und Pressetext-Optimierung müssen sehr sauber gebaut werden. Eigene Test-Suite mit Vorher/Nachher-PMs. + +- **Slot-Inventory:** Wie viele Top-Slots parallel? Empfehlung 1 (sonst verliert er an Wert), Startseite-Highlight 3, Kategorie-Highlight 5–10 je Branche. + +- **Editorial-Picks:** Wer wählt aus? Anfangs du selbst, später ggf. Redaktions-Account mit Frontend-Tool. \ No newline at end of file diff --git a/docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md b/docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md new file mode 100644 index 0000000..d2b13bd --- /dev/null +++ b/docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md @@ -0,0 +1,277 @@ +# Umsetzung Pressemitteilung bearbeiten: Titelbild, Rechte, Veröffentlichung + +Stand: 11.06.2026 + +Diese Notiz dokumentiert die zuletzt umgesetzten Anpassungen an der Bearbeitung von Pressemitteilungen im User Panel und an den parallel genutzten Admin-Formularen. + +## Betroffener Bereich + +- Customer Create/Edit: `resources/views/livewire/customer/press-releases/create.blade.php` +- Customer Edit: `resources/views/livewire/customer/press-releases/edit.blade.php` +- Admin Create/Edit: `resources/views/livewire/admin/press-releases/create.blade.php`, `resources/views/livewire/admin/press-releases/edit.blade.php` +- Show/Index (Customer + Admin) für die Termin-Anzeige: `resources/views/livewire/customer/press-releases/{show,index}.blade.php`, `resources/views/livewire/admin/press-releases/{show,index}.blade.php` +- Model (Zeitzonen-Konstante + Accessoren): `app/Models/PressRelease.php` +- Titelbild-Manager: `resources/views/livewire/components/press-release-images-manager.blade.php` +- Platzhalter-Auswahl: `resources/views/livewire/components/press-release-placeholder-picker.blade.php` +- Platzhalter-Dateien: `public/images/press-release-placeholders` +- Layout-CSS: `resources/css/shared/hub-components.css` + +## Titelbild-Platzhalter + +Die Platzhalter für Pressemitteilungs-Titelbilder wurden erweitert. + +- Die Varianten werden über `App\Enums\PressReleasePlaceholder` verwaltet. +- Die SVG-Dateien liegen lokal unter `public/images/press-release-placeholders`. +- Der Picker lädt die lokalen Varianten in `resources/views/livewire/components/press-release-placeholder-picker.blade.php`. +- Das Modal wurde für mehr Varianten verbreitert und mit einer scrollbaren Grid-Darstellung versehen. + +Ziel: Wenn noch kein eigenes Titelbild vorhanden ist, kann ein optisch passender Platzhalter gewählt werden. + +## Titelbild-Upload + +Der Upload wurde auf ein einzelnes Titelbild begrenzt. + +- Es kann vorerst nur ein Titelbild pro Pressemitteilung hochgeladen werden. +- Wenn ein Titelbild vorhanden ist, wird die Platzhalter-Card ausgeblendet. +- Das Upload-Formular wird ebenfalls ausgeblendet, solange ein Titelbild existiert. +- Das vorhandene Titelbild wird in einer eigenen Bild-Card angezeigt. +- In der Bild-Card werden Titel, Größe und Bildnachweis/Copyright angezeigt. +- Das Titelbild kann gelöscht werden; danach erscheinen Platzhalter und Upload-Formular wieder. + +Das Upload-Formular ist einklappbar: + +- Im Ausgangszustand erscheint nur der Hinweis, dass ein Titelbild fehlt. +- Über „Eigenes Titelbild hochladen" wird das Formular geöffnet. +- Über „Abbrechen" wird das Formular wieder geschlossen und zurückgesetzt. + +## Bildverarbeitung und Speicherverhalten + +Die Bildverarbeitung wurde auf die Titelbild-Nutzung optimiert. + +- Erlaubte Formate: JPG, PNG, WebP. +- Maximale Dateigröße: 16 MB. +- Nicht previewfähige Dateien wie TIFF lösen keine Livewire-Preview-Exception mehr aus; sie werden über die Validierung abgefangen. +- Für Pressemitteilungsbilder wird eine Cover-Variante erzeugt: 1280 x 580 px. +- Der kanonische Bildpfad zeigt auf die Cover-Variante. +- Das Original wird nach der Variantenerzeugung gelöscht, um Speicherplatz zu sparen. +- Breite und Höhe in der Oberfläche beziehen sich auf die gespeicherte Cover-Version, nicht auf das Original. + +Relevante Datei: `app/Services/Image/ImageService.php`. + +## Lizenz- und Rechteformular + +Das Formular wurde an die Vorgaben aus `docs/user-admin/Lizenztyp Bildupload.md` angepasst. + +Erfasste Felder: + +- Titel / Alt-Text über das bestehende `title`-Feld. +- Öffentlicher Bildnachweis über das bestehende `copyright`-Feld. +- Urheber / Fotograf / Rechteinhaber über `author`. +- Lizenztyp über `license_type`. +- Lizenzdetails über `license_detail`. +- Lizenz-URL über `license_url`. +- Quelle / Fundstelle über `source_url`. +- Personenrechte über `people_rights_status`. +- Marken, Kunstwerke, geschützte Werke oder private Orte über `property_rights_status`. +- Interne Notizen über `rights_notes`. +- Rechtebestätigung über `rights_confirmed_at`. + +Lizenztypen: + +- Eigene Aufnahme +- Creative-Commons-Lizenz +- Agentur-/Stockbild-Lizenz +- Vom Urheber / Fotografen freigegeben +- Presse-/PR-Bild mit Nutzungsfreigabe +- Gemeinfrei / Public Domain / CC0 +- Sonstige Lizenz / Sondervereinbarung + +Wichtige Regeln: + +- „Bitte wählen" ist der Ausgangszustand. +- „Unsicher" wurde aus den Auswahlmöglichkeiten entfernt. +- Am Ende muss der Uploadende die Verantwortung für die Rechte bestätigen. +- Creative-Commons-Lizenzen erfassen zusätzlich die konkrete CC-Variante. +- CC-, Stock-/Agentur- und Presse-/PR-Lizenzen verlangen eine Lizenz- oder Nachweis-URL. +- „Sonstige Lizenz / Sondervereinbarung" verlangt einen Pflicht-Freitext. +- Risikohinweise werden bei eingeschränkten oder unklaren Lizenzfällen angezeigt. + +Relevante Dateien: + +- `app/Enums/ImageLicenseType.php` +- `app/Models/PressReleaseImage.php` +- `database/migrations/2026_06_10_154249_add_rights_detail_fields_to_press_release_images_table.php` +- `resources/views/livewire/components/press-release-images-manager.blade.php` + +## Veröffentlichung + +Die Veröffentlichungs-Box wurde vereinfacht. + +Sichtbar sind nur noch: + +- Sofort nach Freigabe +- Geplanter Termin + +Embargo / Sperrfrist wurde in den Formularen aus der Oberfläche entfernt, weil es aktuell noch keine sinnvolle Anwendung im User-Flow gibt. + +Technisches Verhalten: + +- `scheduled_at` bleibt erhalten und wird weiterhin gespeichert. +- `embargo_at` wird in den betroffenen Formularen nicht mehr gesetzt und beim Speichern auf `null` geführt. +- Für den geplanten Termin wird `flux:date-picker` für das Datum verwendet. +- Für die Uhrzeit wird `flux:time-picker` verwendet. +- Intern werden Datum und Uhrzeit wieder zu `scheduledAt` kombiniert. +- Der geplante Termin muss mindestens 5 Minuten in der Zukunft liegen. +- Bei zu frühem Termin wird direkt ein Fehler gesetzt; beim Speichern greift die Validierung ebenfalls. + +Betroffene Properties: + +- `scheduledDate` +- `scheduledTime` +- `scheduledAt` + +## Zeitzonen-Handling für geplante Veröffentlichung + +Stand: 11.06.2026 (nachgezogen) + +Die Anwendung läuft serverseitig in UTC (`config/app.php` → `timezone = 'UTC'`). +Geplante Termine werden aber von Redaktion und Kunden in **deutscher Zeit** +gedacht. Vorher wurde die im Formular eingegebene Uhrzeit naiv als UTC +interpretiert, wodurch die Veröffentlichung um den Berlin-Offset (im Sommer ++2 h) verschoben stattfand. Das ist behoben. + +Grundprinzip: + +- Eingabe und Anzeige erfolgen in **Europe/Berlin**. +- Gespeichert wird weiterhin **UTC**. +- Wichtig: Laravel konvertiert beim Speichern **nicht** automatisch nach UTC. + Deshalb wird der eingegebene Wert beim Parsen explizit als Berlin + interpretiert und mit `->utc()` umgewandelt; beim Laden wird umgekehrt von + UTC nach Berlin gewandelt. + +Zentrale Stelle: + +- `App\Models\PressRelease::DISPLAY_TIMEZONE` (`'Europe/Berlin'`) ist die + Single Source of Truth. +- `PressRelease::scheduledAtLocal()` und `PressRelease::embargoAtLocal()` + liefern die Termine in der Anzeige-Zeitzone für alle Views. + +Verhalten in den Formularen (Customer **und** Admin, Create **und** Edit): + +- Helper `scheduledAtUtc()`: parst die naiven Eingabefelder als Berlin und + gibt den UTC-Zeitpunkt für die Speicherung zurück. +- Die „mind. 5 Minuten in der Zukunft"-Prüfung läuft jetzt über eine + zeitzonenbewusste Closure-Regel statt über die naive `after:`-Regel. +- Beim Bearbeiten (`mount`) wird `scheduled_at` von UTC nach Berlin gewandelt, + bevor Datum/Uhrzeit in die Eingabefelder gefüllt werden. + +Anzeige (lokalisiert auf Berlin): + +- Customer-Show/-Index und Admin-Show/-Index: geplanter Termin und Embargo + werden über `scheduledAtLocal()` / `embargoAtLocal()` ausgegeben. + +Bewusst (noch) nicht umgestellt: + +- `published_at`, `created_at` und die Status-Log-Zeitstempel werden weiterhin + in UTC angezeigt. Eine vollständige Anzeige-Lokalisierung dieser Felder ist + als Folgeschritt vorgesehen. + +## Aufräumung: Scheduling-Logik und Queries + +Im Zuge der Zeitzonen-Umstellung wurden zwei Altlasten in allen vier +PM-Formularen bereinigt: + +- **Doppelte Termin-Synchronisierung entfernt:** Die Termin-Logik lief vorher + sowohl im generischen `updated()`-Hook als auch in den spezifischen + `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks – also doppelt. + `updated()` enthält jetzt nur noch die generische Re-Validierung bereits + fehlerhafter Felder; die Synchronisierung liegt ausschließlich in den + spezifischen Hooks. +- **Redundante Queries reduziert:** Im Customer-Edit wird die geladene + Pressemitteilung pro Request memoisiert (`mount()`, `with()` und `save()` + greifen sonst jeweils mit einer eigenen Query auf dieselbe PM zu). + +## Responsive Layout + +Das Layout der Pressemitteilungsformulare wurde entkoppelt von der globalen Sidebar-Logik. + +Die globale Flux-Sidebar bleibt im stabilen Standardzustand: + +- `flux:sidebar sticky stashable` +- Header- und Toggle-Sichtbarkeit weiter über `lg:hidden` + +Das eigentliche Formularlayout wird über eigene Klassen gesteuert: + +- `.pr-editor-layout` +- `.pr-editor-side` + +Regel in `resources/css/shared/hub-components.css`: + +```css +@media (min-width: 1180px) { + .pr-editor-layout { + grid-template-columns: minmax(0, 1fr) 360px; + } + + .pr-editor-side { + position: sticky; + top: 1rem; + } +} +``` + +Verhalten: + +- Unter 1180 px steht die rechte Formularbox unterhalb des Hauptinhalts. +- Ab 1180 px steht die rechte Formularbox wieder rechts. +- Die rechte Box wird ab 1180 px sticky. +- Die globale linke Navigation bleibt davon unberührt. + +## Button-Varianten + +Sekundäre `variant="ghost"`-Buttons in Blade-Views wurden breit auf `variant="filled"` umgestellt, weil die Ghost-Buttons optisch zu wenig als Buttons erkennbar waren. + +Umfang: + +- Alle Blade-Views unter `resources/views` wurden auf verbleibende `variant="ghost"` geprüft. +- Markdown-Dokumentation wurde dabei nicht als UI geändert. + +## Tests und Verifikation + +Ergänzte bzw. angepasste Tests: + +- `tests/Feature/PressReleasePlaceholderTest.php` +- `tests/Feature/PressReleaseImageLicenseTest.php` +- `tests/Feature/CustomerPressReleaseSchedulingFormTest.php` +- `tests/Feature/Admin/AdminPressReleaseSchedulingTest.php` + +Für die Zeitzonen-Umstellung zusätzlich angepasst (Assertions auf +Berlin-Werte umgestellt): + +- `tests/Feature/PressReleaseShowPhase8aTest.php` +- `tests/Feature/Admin/AdminPressReleaseShowTest.php` + +Zuletzt erfolgreich ausgeführte Checks: + +- `php artisan test --compact tests/Feature/CustomerPressReleaseSchedulingFormTest.php tests/Feature/Admin/AdminPressReleaseSchedulingTest.php tests/Feature/Admin/AdminPressReleaseShowTest.php tests/Feature/PressReleaseShowPhase8aTest.php tests/Feature/PressReleaseIndexPhase8bTest.php` +- `vendor/bin/pint --dirty --format agent` +- Volle Suite: 400 passed (die zwei roten Tests `PressReleaseImageApiTest` und + `CustomerPressReleaseCreatePhase7Test` sind vorbestehende, unabhängige WIP- + Failures – per Stash-Test verifiziert, dass sie auch ohne diese Änderungen + scheitern). + +Vorher zusätzlich grün gelaufen: + +- `php artisan test --compact tests/Feature/PressReleasePlaceholderTest.php tests/Feature/PressReleaseImageLicenseTest.php tests/Feature/CustomerPressReleaseEditPhase7Test.php` +- Phase-8-nahe Show-/Index-/Attachment-/Admin-Tests. + +## Bewusst noch nicht umgesetzt + +- Optionaler Upload von Lizenznachweisen oder Freigabe-Dokumenten. +- Reaktivierung des separaten Anhang-Managers. +- Manuelle redaktionelle Prüf-Workflows für riskante Lizenzfälle. +- Vollständige Medienbibliothek statt einzelnes Titelbild. +- Frontend-Ausgabe des Bildnachweises außerhalb der Bearbeitungsoberfläche, sofern noch nicht separat angebunden. +- Anzeige-Lokalisierung von `published_at`, `created_at` und Status-Log-Zeitstempeln (aktuell weiterhin UTC). +- Klärung/Anpassung der Auto-Publish-Policy: geplante PMs (Status `review` + fälliger `scheduled_at`) werden vom Scheduler-Command automatisch veröffentlicht – ohne separate redaktionelle Freigabe. Wird in einem eigenen Schritt geprüft. + diff --git a/docs/user-admin/User-Panel-Restarbeiten.md b/docs/user-admin/User-Panel-Restarbeiten.md new file mode 100644 index 0000000..2890541 --- /dev/null +++ b/docs/user-admin/User-Panel-Restarbeiten.md @@ -0,0 +1,34 @@ +1. Pressemitteilungen anlegen ohne Firma hier würde ich das Create Formular nicht aufrufen lassen, wenn keine Firma existiert, sondern es muss eine Meldung erscheinen, dass ohne eine Firma keine Pressemitteilung angelegt werden kann.. Hintergrund öffnet man. Eine neue Pressemitteilung kann man keine Firma angeben und kann auch nicht speichern man muss wieder zurückspringen eine Firma anlegen und dann kann man erst entsprechend ein Presse Beitrag erstellen. + +Profil +1. https://pressekonto.test/admin/me/profile die Darstellung ist noch nicht wirklich sauber hier kann noch etwas mehr Struktur rein. Grundsätzlich würde ich ab sofort Rechnungsadresse als Pflicht machen, wenn ein Paket gebucht wird und diese Rechnungsdaten gleich mit übergeben. Profil Einstellungen. Hier gibt es auch schon so etwas wie Anrede Vorname Telefon Risse etc. dieses doppelt sich mit der Rechnungsadresse zumindest in Teilen, denn die Rechnungsadresse ist technisch eigentlich gar nicht richtig vollständig hier viel zum Beispiel Firmenname Vorname Nachname Anrede. + +2. Validierung der Rechnungsadresse gebe ich etwas ein und drücke ich auf. Absenden bekomme ich die Fehlermeldung zweimal einmal direkt unter dem Feld und einmal unten. + +3. Umsatzsteuer ID. Ich würde hier gerne schon mal eine Validierung der Umsatzsteuer ID haben, ob diese gültig ist oder macht das an dieser Stelle gar keinen Sinn, weil Strip das komplett übernimmt? Ich glaube es wär aber gut, wenn Firmen aus EU Land hier schon eine Steuernummer hinterlegen können und diese zumindest schon einmal während der Eingabe validiert wird, ob diese richtig ist. Diese kann dann ja Stripe übergeben werden. https://api.evatr.vies.bzst.de/app/v1/abfrage + +4. Unten gibt es noch Einstellungen Statistiken anzeigen und Footer code deaktivieren haben die aktuell eine Funktion? + +Firmen: +Wenn ich auf die Firmenübersicht gehe und eine Firma ein Logo hat, soll das auch in der Übersicht erscheinen und angezeigt werden. Jetzt ist da dann immer der Kürzel der Firma + +Bei der Firma ist zusätzlich angegeben, letzte PM ihr fehlt Jahreszahl + +Einheitlichkeit. + +Statt Checkboxen, bitte die Switcher benutzen von Flux UI. + +--- + +## Status (Claude, 12.06.2026) + +| # | Punkt | Status | +|---|---|---| +| PM 1 | PM-Anlage ohne Firma | ✅ Das Formular erscheint nicht mehr; stattdessen Meldung „Ohne Firma kann keine Pressemitteilung angelegt werden" mit Button „Firma anlegen". | +| Profil 1 | Struktur + Rechnungsadresse vollständig | ✅ Seite neu gegliedert (Persönliche Daten / Konto & Sicherheit / Rechnungsadresse / Einstellungen). Rechnungsadresse hat jetzt Anrede, Vorname, Nachname, Firmenname (optional) + Anschrift, mit Unterabschnitten Empfänger/Anschrift/Steuern und Button „Persönliche Daten übernehmen" gegen die Doppel-Eingabe. Rechnungsadresse ist ab sofort **Pflicht für jede Buchung** (Checkout leitet sonst mit Hinweis aufs Profil); Adresse und USt-ID werden an Stripe übergeben. | +| Profil 2 | Doppelte Fehlermeldung | ✅ Statt einer generischen Sammelmeldung + Feldmeldung werden fehlende Pflichtfelder jetzt einzeln und genau einmal unter dem jeweiligen Feld gemeldet. | +| Profil 3 | USt-ID-Validierung | ✅ Zweistufig: Formatprüfung sofort bei der Eingabe (hartes Gate beim Speichern), Online-Bestätigung über die BZSt-eVatR-REST-API (`api.evatr.vies.bzst.de`) live während der Eingabe. **Wichtig:** eVatR kann nur ausländische EU-IDs bestätigen und braucht unsere eigene deutsche USt-ID → ENV `BILLING_OWN_VAT_ID` setzen. Deutsche IDs werden nur formatgeprüft. Antwort auf die Frage: Stripe validiert die im Checkout erfasste ID zusätzlich selbst — unsere Prüfung verbessert die Eingabe-UX und sichert den manuellen MAN-Rechnungskreis ab, ersetzt Stripe also nicht, ergänzt es. Die lokal gepflegte USt-ID wird beim Checkout als Stripe-Tax-ID übergeben. | +| Profil 4 | Schalter „Statistiken anzeigen" / „Footer-Code deaktivieren" | ⚠️ Befund: Beide Flags werden gespeichert (auch aus dem Legacy-Import), aber **noch nirgends ausgewertet** — die konsumierenden Features (öffentliche PM-Seiten mit Footer-Codes/Statistiken) kommen mit dem Web-Relaunch. Die Beschreibungen weisen jetzt darauf hin („Greift mit dem Relaunch der Portal-Seiten"). Entscheidung offen: behalten (empfohlen, Daten sind da) oder bis zum Relaunch ausblenden. | +| Firmen 1 | Logo in der Übersicht | ✅ Die Übersicht nutzt jetzt dieselbe Logo-Auflösung wie die Detailseite (inkl. migrierter Legacy-Pfade) — vorher fielen alle Legacy-Firmen auf die Initialen zurück. | +| Firmen 2 | „Letzte PM" ohne Jahr | ✅ Kartenansicht zeigt jetzt `d.m.Y`. | +| Einheitlichkeit | Checkboxen → Flux-Switches | ✅ Umgestellt (Boilerplate-Override in PM-Create/-Edit, Footer-Code in Firma-Anlage/-Detail). Ausnahme: die Mehrfachauswahl der API-Token-Berechtigungen bleibt eine Checkbox-Gruppe — dort ist die Checkbox die semantisch richtige Flux-Komponente. | \ No newline at end of file diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md new file mode 100644 index 0000000..51b05dc --- /dev/null +++ b/docs/user-admin/checkliste-user-backend.md @@ -0,0 +1,164 @@ +# Checkliste User Backend + +Stand: 11.06.2026 (Phase 7 + Phase 8 + KI-Pruef-Pipeline abgeschlossen; naechster Block: Zahlung/Tarife + Veroeffentlichungs-Flow laut Decision-Update) + +Diese Checkliste fasst den aktuellen Stand des User Backends zusammen und trennt erledigte Punkte von den naechsten sinnvollen Umsetzungsschritten. + +Begleitende Dokumente: + +- `docs/STATUS-ABGLEICH-USER-PANEL.md` — Konzept-vs-Code-Abgleich pro Page. +- `docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md` — verbindliche Launch-Entscheidungen zu Tarifen, Kontingenten und Flow. +- `docs/user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md` — KI-Klassifikation + Content-Score (Phasen 0–5 erledigt). +- `docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md` — Titelbild/Lizenzformular/Zeitzonen-Umbau (10./11.06.). +- `docs/PHASE-8-USER-PANEL-PLAN.md` — Phase-8-Plan (abgeschlossen). +- `dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md` — Phase-7-Abschluss. +- `dev/frontend/hub-flux/PROGRESS.md` — Tagebuch der Hub-Migration. + +## Erledigt + +- [x] User Backend und Admin Backend konzeptionell getrennt, technisch weiter im gemeinsamen Backend mit rollenbasierter Sicht. +- [x] User-Backend-Navigation in die Bereiche „Mein Bereich", „Finanzen" und „Konto" gegliedert. +- [x] Firmen-Kontext-Switcher aus der Sidebar in eine Topbar rechts ueber dem Content verschoben. +- [x] Topbar technisch in `` integriert, damit sie nicht mit Sidebar oder Content kollidiert. +- [x] Topbar visuell vom Content abgesetzt. +- [x] Firmen-Kontext mit „Alle Firmen" und Einzelfirma als globale User-Backend-Auswahl umgesetzt. +- [x] Dashboard und Pressemitteilungs-Liste reagieren auf den aktiven Firmen-Kontext. +- [x] Firmen-Liste und Firmen-Detail im User Backend umgesetzt. +- [x] Terminologie in der Navigation von „Pressemappen" auf „Firmen" vereinheitlicht. +- [x] Legacy-URLs fuer `pressemappen` auf neue `firmen`-Routen weitergeleitet. +- [x] Firmen-Stammdaten im Firmen-Detail bearbeitbar gemacht, inklusive Logo. +- [x] Legacy-Company-Logos bevorzugt lokal aufgeloest, um 403-Fehler durch externe Alt-URLs zu vermeiden. +- [x] Firmen-Logos in Admin- und User-Ansichten auf sinnvolle Groessen begrenzt. +- [x] Kontaktverwaltung innerhalb einer Firma umgesetzt. +- [x] Rechte fuer Firmen- und Kontaktverwaltung getrennt: Owner/Verantwortliche duerfen bearbeiten, Mitglieder bleiben lesend. +- [x] Neue Pressemitteilungen uebernehmen aktive Firma als Vorauswahl. +- [x] Portal der Pressemitteilung wird aus der Firma abgeleitet statt manuell gewaehlt. +- [x] Pressemitteilungs-Detail zeigt zugeordnete Pressekontakte. +- [x] Pressemitteilungs-Detail zeigt Status- und Verlaufsdaten. +- [x] Profilseite auf persoenliches Profil reduziert; Firmendaten werden in der jeweiligen Firma gepflegt. +- [x] Rechnungsadresse als eigener sichtbarer Bereich im Profil aufgenommen. +- [x] Rechnungen in die Finanznavigation verschoben. +- [x] Rechnungsseite mit Hinweisblock zu Legacy-Rechnungen und Link zur Rechnungsadresse versehen. +- [x] „Buchungen & Add-ons" als vorbereiteter Bereich eingebunden. +- [x] API-Tokens unter „Konto" als „API & Integrationen" eingeordnet. +- [x] Dashboard zeigt erste Datenqualitaets-Hinweise aus bestehenden Tabellen. +- [x] Phase-1-Dokumentation in `Admin-User.md` aktualisiert. +- [x] Regressionstests fuer Firmen-Kontext, Navigation, Profil, Firmen und Sicherheitsgrenzen ergaenzt. + +## Naechste sinnvolle Schritte + +- [x] Topbar fachlich abrunden: links optional Seitentitel/Kontext aufnehmen, rechts Firmen-Switcher und spaeter weitere kompakte Tools. +- [x] Mobile Darstellung der Topbar pruefen und bei Bedarf vereinfachen, damit der Switcher auf kleinen Displays nicht zu breit wird. +- [x] Firmen-Switcher um direkten Link „Firma oeffnen" oder „Firma verwalten" fuer die aktive Firma erweitern. +- [x] Leere und fehlerhafte Zustaende in Firmen, Kontakte, Pressemitteilungen und Rechnungen visuell vereinheitlichen. +- [x] Dashboard-Hinweise klickbar machen, sodass User direkt zur passenden Korrekturstelle springen. +- [x] Pressemitteilungs-Liste um Filter fuer „ohne Firma", Status und aktive Firma sauber abrunden. +- [x] Firmen-Detail in klare Tabs oder Sektionen aufteilen: Stammdaten, Pressekontakte, Pressemitteilungen, Abrechnung, Statistik. +- [x] Rechnungsadresse validieren und klarer anzeigen, welche Daten fuer kuenftige Rechnungen fehlen. +- [x] Sicherheit/Konto-Bereich weiter ausbauen: Passwort, 2FA, Sessions und Login-Hinweise konsolidieren. +- [x] UI-Texte und Begriffe final durchgehen: Firma, Pressemappe, Pressemitteilung, User Backend, Admin Backend. +- [x] Breitere visuelle QA im User Backend: Tabellenabstaende, Karten, Header, responsive Verhalten. + +## Phase 7 — Pressemitteilungs-Form-Refactor (abgeschlossen) + +- [x] Pressemitteilungen unterstuetzen einen Untertitel (`press_releases.subtitle`). +- [x] Pressemitteilungen unterstuetzen geplante Veroeffentlichung (`scheduled_at`) und Embargo (`embargo_at`). +- [x] Pressemitteilungen unterstuetzen ein optionales Boilerplate-Override (`boilerplate_override`). +- [x] PM-Inhalt wird vor dem Speichern serverseitig sanitiert (HTMLPurifier via `mews/purifier`, gekapselt in `PressReleaseHtmlSanitizer`). +- [x] Customer- und Admin-Forms verwenden den gleichen Sidebar-Aufbau: + Status & Absenden, Kategorie, Portal (als farbiger Pill), Pressekontakt, Themen-Tags, Veroeffentlichung, Weitere Felder, Phase-2-Footer. +- [x] Pressekontakt-Pflichtfeld aufgehoben — Auswahl bleibt empfohlen, ist aber technisch nullable. +- [x] Anhaenge-/Downloads-UI ist im Customer- und Admin-Editor deaktiviert (Tabelle `press_release_attachments` und Manager-Komponente bleiben fuer einen spaeteren Security-Review erhalten). +- [x] Hintergrund-Command `php artisan press-releases:publish-scheduled` veroeffentlicht geplante PMs (Scheduler-Eintrag in `routes/console.php`, Intervall 5 Min). +- [x] FluxUI Toast (`Flux::toast()`) wird konsistent fuer Erfolg, Fehler und Validation-Probleme in PM-Forms und Status-Aktionen verwendet. +- [x] Smooth-Scrolling zum ersten Validation-Fehler nach Save (`resources/js/portal-form-hooks.js`). +- [x] Pre-Submit-Check-Liste (`@computed presubmitChecks`) zeigt vor dem Einreichen offene Pflichtfelder und Empfehlungen. + +## Phase 8 — User-Panel-Konsolidierung (abgeschlossen) + +Vollstaendiger Plan: `docs/PHASE-8-USER-PANEL-PLAN.md`. Roadmap-Abschluss: +`dev/frontend/hub-flux/20-PHASE-8-USER-PANEL.md`. + +- [x] Show-Page-Luecken schliessen (Subtitle, Scheduling, Embargo, Boilerplate-Override) — Customer + Admin (8A). +- [x] Listen-Indikatoren fuer geplante Veroeffentlichung und Embargo (8B). +- [x] Pressekontakt-Warn-Box in Sidebar-Card, wenn kein Kontakt gewaehlt (8C). +- [x] Doku-Pflege: `docs/user-admin/*` an IST-Stand ziehen (8D, dieses Dokument). +- [x] Firmen-Liste auf Mockup-Niveau (Counter-Strip, Saved-Views, Filter-Chips, Card/List-Toggle, Rollen-Legende) (8E) — Tests: `CustomerPressKitIndexPhase8eTest`, `CustomerPressKitCreatePhase8eTest`. +- [x] Set wiederverwendbarer SVG-Platzhalter fuer PM-Titelbilder + Auswahl-Modal (8F) — `App\Enums\PressReleasePlaceholder`, ``, Picker `components.press-release-placeholder-picker`. +- [x] Titelbild-Schema in `press_releases` (`placeholder_variant`, deterministischer Default, `PressReleaseCoverImage`-Resolver, Hero in Customer-/Admin-Show) (8G). +- [x] Bild-Upload mit Lizenz-Pflichtfeldern (Urheber, Lizenz-Typ, Lizenz-URL bedingt, Personen-Einwilligung, Rechte-Bestaetigung) im Image-Manager (8H). Hinweis: Upload-Control bleibt `flux:input type=file` statt `flux:file-upload` (Stabilitaet); Lizenzerfassung vollstaendig. +- [x] Veroeffentlichungs-Modal mit rechtlichen Hinweisen (Platzhalter, anwaltlich zu pruefen) + Kontingent-Anzeige (Customer-Show) (8I). +- [x] Kontingent-Stub im Datenmodell (`users.press_release_quota` + `..._used_this_month`, Decrement in `submitForReview`, monatlicher `press-releases:reset-monthly-quota`-Command) (8J). +- [x] Tests, Pint, Build, Roadmap-Update (8K). + +## KI-Pruef-Pipeline — Klassifikation & Content-Score (abgeschlossen 11.06.2026) + +Vollstaendiger Plan mit Phasen-Details: `docs/user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`. + +- [x] Einreichungs-Modal vereinheitlicht: `confirm-submit-review` in Customer-Show, -Create und -Edit (Phase 0). +- [x] API-Absicherung: `status` nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Phase 1). +- [x] Datenmodell: `press_releases.classification`/`classified_at`, `ki_audits`-Audit-Tabelle (Phase 2). +- [x] KI-Klassifikation Rot/Gelb/Gruen, asynchron ueber Queue `classification`, OpenAI-Treiber + deterministischer Fallback (Phase 3). +- [x] Status-Routing: Rot → abgelehnt + Mail, Gelb → manuelle Admin-Queue, Gruen → Auto-Publish (sofort/zum Termin); Scheduler publiziert nur gruene PMs (Phase 4). +- [x] Admin: KI-Badge + Klassifikations-Filter im Index, KI-Begruendung in der Show, On-Demand-„Pruefung"-Button mit Anbieter-Override. +- [x] Re-Klassifikation und Re-Score bei Titel-/Text-Aenderung (Customer, Admin, API). +- [x] Content-Score 0–100 → Stufe Standard/Geprueft/Hochwertig inkl. Editor-Panel und Badges (Phase 5). + +## Titelbild, Lizenzen & Termin-Handling (abgeschlossen 10./11.06.2026) + +Details: `docs/user-admin/Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`. + +- [x] Upload auf ein Titelbild pro PM begrenzt; Cover-Variante 1280×580, Original wird nach Verarbeitung geloescht. +- [x] Lizenz-/Rechteformular nach `Lizenztyp Bildupload.md` erweitert (7 Lizenztypen, Personen-/Sachrechte-Status, Quelle, Notizen, Risikohinweise). +- [x] Veroeffentlichungs-Box vereinfacht: nur „Sofort nach Freigabe" und „Geplanter Termin"; Embargo aus der Form-UI entfernt (`embargo_at` bleibt im Schema). +- [x] Zeitzonen-Handling: Eingabe/Anzeige Europe/Berlin, Speicherung UTC (`PressRelease::DISPLAY_TIMEZONE`, `scheduledAtLocal()`). +- [x] PM-Editor-Layout responsive entkoppelt (`.pr-editor-layout`); Ghost-Buttons auf `filled` umgestellt. + +## Naechster Block — Zahlung, Tarife & Veroeffentlichungs-Flow (Launch) + +Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`. Umsetzungsplan: `docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`. + +- [x] Gelb-Routing auf Direkt-Live umstellen (Entscheidung 12.06.: Gelb geht wie Gruen online, keine manuelle Queue; nur Rot wird abgelehnt) — Phase 9A. +- [x] Slot-Verbrauch von Einreichung auf **Veroeffentlichung** umstellen (Rot = kein Slot-Verbrauch, idempotent ueber Status-Logs; Submit-Guard bei 0 Rest-Slots) — Phase 9B. +- [x] Submit-Gate vorbereitet: `User::hasActiveBooking()`-Stub (`billing.enforce_booking`, Default aus), Buchungs-Hinweis im Modal, Server-Guard + API 402 — Phase 9C. Echte Buchungs-Pruefung kommt mit dem Tarif-Modul. +- [x] Funnel-Luecke geschlossen: Create-Form legte PMs direkt mit Status `review` an (ohne Blacklist/Quota/KI/Status-Log) — laeuft jetzt ueber `submitForReview` (9C). +- [x] Tarif-Datenmodell (Phase 9D, 12.06.): `plans` (4 Tiers + Seeder), `single_purchases` (Einzel-PM/Extra-PM/Boost/PDF), Laravel Cashier installiert (`User` ist Billable), `hasActiveBooking()` prueft hybrid (Stripe-Abo / Einmalkauf / Legacy-Vereinbarung). +- [x] Hybride Rechnungskreise (Entscheidung 12.06.): fortlaufende Nummern via `InvoiceNumberGenerator` — **STR-** fuer den neuen Stripe-Shop, **MAN-** fuer laufende Legacy-Zahlungen; Alt-Archiv (`legacy_invoices`) bleibt unveraendert. +- [x] MAN-Faelligkeitslauf: `billing:generate-manual-invoices` (taeglich 04:30) prueft `user_payment_options` ohne Stripe-Subscription auf erreichtes Periodenende, stellt Rechnung mit Adress-Snapshot aus und schaltet die Periode weiter (Konditions-Overrides via `legacy_conditions`). +- [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. +- [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich). +- [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung. +- [x] Stripe-Checkout + Webhooks (Phase 9E, 12.06.): Produkt-Sync nach Stripe (Tarife + Einzel-PM, netto, Stripe Tax), STR-Rechnungsspiegelung + Einmalkauf-Erfuellung per Webhook (Endpoint registriert), Checkout-Flows als Backend (`me.checkout.subscription`/`me.checkout.single-pm`), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Bestandsschutz), Quota-Stub-Spalte entfernt. UI-Anbindung folgt in 9F. Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`. +- [x] Tarif-Seite + Checkout-UI (Phase 9F, 12.06.): Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, "2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige (unbegrenzt), "Abo verwalten" via Stripe Billing Portal; Credit-Mock abgeloest (Credits → 9I/Phase 2). +- [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. +- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. +- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). +- [ ] Rechtstexte im Einreichungs-Modal anwaltlich pruefen lassen (Platzhalter, Go-Live-Blocker). +- [ ] Queue-Worker fuer `classification` im Produktions-Setup verankern. + +## Phase 2 / spaeter + +- [ ] Vorab-KI-Pruefung, Redigieren/Nachbessern + Re-Check-Loop, Pruefzaehler und Credit-Overflow (Decision-Update §7). +- [ ] Magic-Link-Zugriff fuer Firmen-E-Mail-Adressen konzipieren und umsetzen. +- [ ] Separate `token_requests`-Tabelle fuer nicht-userbasierte Zugriffe anlegen. +- [ ] Zugriff per Firmen-E-Mail so begrenzen, dass nur passende Firmen und Pressemitteilungen sichtbar werden. +- [ ] Trust Score fuer User/Firmen konzipieren und im Admin Backend justierbar machen (KI-Plan Phase 6). +- [ ] Moderationslogik an Trust Score anbinden (Klassifikations-Routing existiert bereits). +- [ ] Stufen-Badges (Geprueft/Hochwertig) im oeffentlichen Web-Frontend ausgeben. +- [ ] Aufbewahrungsfristen fuer Magic Links, Token Requests, API Logs, Statuslogs und `ki_audits`-Raw-Responses (DSGVO) definieren und technisch absichern. +- [ ] Admin-editierbare Textvorlagen fuer neutrale Tombstone-/Entfernungs- und Systemtexte einbauen. +- [ ] API-Nutzungs-Log im User Backend sichtbar machen. +- [ ] Benachrichtigungen und Newsletter-Abos im Konto-Bereich ausbauen. +- [ ] Zahlungsarten und firmenbezogene Zahlungsoptionen im User Backend aktivieren. +- [ ] Statistikbereich fuer Firmen und Pressemitteilungen umsetzen. +- [ ] Medienbereich aus vorhandenen Pressemitteilungsbildern ableiten; spaeter echte Medienbibliothek pruefen. +- [ ] Team-/Rollenverwaltung fuer Firmen im User Backend ergaenzen. +- [ ] Anzeige-Lokalisierung von `published_at`, `created_at` und Status-Log-Zeitstempeln (aktuell UTC). + +## Hinweise + +- Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Phasen 0–5) sind abgeschlossen — siehe Plan-Dokus oben. +- Fuer Preise, Kontingente und den Veroeffentlichungs-Flow gilt ausschliesslich das Decision-Update vom 11.06.2026; aeltere Tarif-Tabellen in `Konzept-Update 1` und im Relaunch-Konzept sind ueberschrieben. +- Das PM-Kontingent kommt aus dem Tarif (`plans.press_release_quota`) plus Einmalkaeufen; Bestandskunden (Grandfathered) sind unbegrenzt. Solange `billing.enforce_booking` aus ist, gilt kein Kontingent (Launch-Schalter). +- Die KI-Klassifikation laeuft asynchron — in Produktion wird ein Queue-Worker fuer die Queue `classification` benoetigt (Test-Drain: `php artisan classification:work`). +- Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert. diff --git a/docs/user-admin/user-zusammenhaenge.md b/docs/user-admin/user-zusammenhaenge.md new file mode 100644 index 0000000..4561ea2 --- /dev/null +++ b/docs/user-admin/user-zusammenhaenge.md @@ -0,0 +1,329 @@ +# User-Admin: Zusammenhänge und relevante Daten + +Stand: 2026-06-11 (aktualisiert nach Phase 8 + KI-Pipeline) + +Diese Notiz beschreibt den User als fachlichen Mittelpunkt für die weitere Konzeption des Admin-User-Bereichs. Grundlage sind die aktuellen Models und Migrationen im Laravel-Projekt. + +> **Was seit Phase 8 + KI-Pipeline dazugekommen ist (29.05.–11.06.2026)**: +> +> - `press_releases`: `placeholder_variant` (SVG-Titelbild-Platzhalter, 8G), +> `classification`/`classified_at` (KI-Klassifikation Rot/Gelb/Grün), +> `content_score`/`content_tier`/`scored_at` (Content-Score). +> - `press_release_images`: Lizenz-/Rechtefelder `author`, `license_type`, +> `license_detail`, `license_url`, `source_url`, `people_rights_status`, +> `property_rights_status`, `rights_notes`, `persons_consent`, +> `rights_confirmed_at` (8H + Erweiterung 10.06.). +> - `users`: Quota-Stub `press_release_quota` + +> `press_release_quota_used_this_month` (8J; wird vom Tarif-Modul abgelöst). +> - Neue Tabelle `ki_audits` (Modell `KiAudit`, append-only Audit-Log jeder +> KI-Entscheidung) mit Relation `PressRelease::kiAudits()`. +> - Neue Enums: `PressReleasePlaceholder`, `ImageLicenseType`, +> `PressReleaseClassification`, `PressReleaseContentTier`. +> - Neue Services/Jobs: `PressReleaseCoverImage` (Cover-Resolver), +> Treiber-Architektur unter `Services/PressRelease/Classification/` und +> `…/ContentScore/`, Jobs `ClassifyPressRelease`/`ScorePressRelease` +> (Queue `classification`), Konfiguration in `config/scoring.php`. +> - Neue Commands: `press-releases:reset-monthly-quota` (Scheduler, 1. des +> Monats), `classification:work` (Queue-Drain zum Testen). +> - Zeitzonen-Konstante `PressRelease::DISPLAY_TIMEZONE` (Europe/Berlin) mit +> `scheduledAtLocal()`/`embargoAtLocal()` für alle Termin-Anzeigen. + +> **Was sich seit dem ursprünglichen Stand (2026-05-05) geändert hat**: +> +> - Pressemitteilungen haben zusätzliche Felder: `subtitle`, `scheduled_at`, +> `embargo_at`, `boilerplate_override`, `no_export` (Phase 7). +> - Neue Tabelle `press_release_attachments` (Modell `PressReleaseAttachment`). +> UI ist temporär deaktiviert (Security-Review), Tabelle und Service +> bleiben aber erhalten. +> - Neuer Service-Layer für PMs: `PressReleaseService` (Status-Übergänge, +> Scheduling-Auflösung), `PressReleaseHtmlSanitizer` (HTMLPurifier-Wrapper), +> `PressReleaseAttachmentStorage`. +> - Neuer Console-Command `php artisan press-releases:publish-scheduled` +> (Scheduler-Intervall 5 Min) veröffentlicht geplante PMs automatisch. +> - Pivot `press_release_contact` bleibt n:m, der Customer-Flow speichert +> aber nur einen Kontakt pro PM und das Feld ist nullable. + +## Zentraler Ausgangspunkt + +Ein `User` ist nicht nur ein Login-Konto, sondern bündelt im System mehrere fachliche Bereiche: + +- Zugang und Status: Name, E-Mail, Verifikation, Passwort, 2FA, Aktiv/Inaktiv, Super-Admin-Flag, Rollen/Berechtigungen. +- Portal- und Legacy-Zuordnung: `portal`, `registration_type`, `language`, `legacy_portal`, `legacy_id`. +- CRM-Zuordnung: Firmen, Firmenrollen, Kontakte. +- Content-Zuordnung: Pressemitteilungen, Bilder, Statusverlauf. +- Abrechnung und Verträge: Rechnungsadresse, Zahlungsoptionen, Zahlungen, Rechnungen, Legacy-Rechnungen. +- API und Sicherheit: Sanctum-Tokens, API-Nutzung, Magic-Links. +- Kommunikation und Arbeitskomfort: Newsletter-Abos, Filter-Presets. + +## Datenmodell im Überblick + +```mermaid +erDiagram + users ||--o| profiles : "hat" + users ||--o| billing_addresses : "hat" + users ||--o{ companies : "owner_user_id" + users }o--o{ companies : "company_user.role" + users }o--o{ contacts : "contact_user" + companies ||--o{ contacts : "hat" + users ||--o{ press_releases : "autor" + companies ||--o{ press_releases : "zugeordnet" + contacts }o--o{ press_releases : "press_release_contact" + press_releases ||--o{ press_release_images : "hat" + press_releases ||--o{ press_release_status_logs : "hat" + users ||--o{ press_release_status_logs : "changed_by_user_id" + users ||--o{ user_payment_options : "hat" + user_payment_options }o--o{ companies : "user_payment_option_company" + user_payment_options ||--o{ user_payments : "hat" + user_payments ||--o{ invoices : "erzeugt" + users ||--o{ invoices : "hat" + users ||--o{ legacy_invoices : "hat" + users ||--o{ newsletter_subscriptions : "hat" + users ||--o{ magic_links : "hat" + users ||--o{ user_filter_presets : "hat" + users ||--o{ api_usage_logs : "hat" +``` + +## Direkte Relationen am User + +`profile` + +- Kardinalität: 1:0..1. +- Tabelle: `profiles`, Foreign Key `user_id` ist eindeutig. +- Inhalt: persönliche Profildaten, Anrede, Titel, Vor-/Nachname, Telefon, Adresse, Land, Geburtsdatum, Backlink, Statistik-/Footer-Code-Flags, Validierungs-/Vertragsdaten, Steuerdaten. +- Relevanz im Admin: Datenqualität, Legacy-Profil, persönliche Stammdaten, Vertrag/Validierung. + +`billingAddress` + +- Kardinalität: 1:0..1. +- Tabelle: `billing_addresses`, Foreign Key `user_id` ist eindeutig. +- Inhalt: Rechnungsname, Adresse, PLZ, Ort, Land. +- Relevanz im Admin: Rechnungsfähigkeit, fehlende Abrechnungsdaten, Kundendatenpflege. + +`ownedCompanies` + +- Kardinalität: 1:n. +- Tabelle: `companies`, Foreign Key `owner_user_id`. +- Bedeutung: Der User ist fachlicher Eigentümer einer Firma, auch ohne Pivot-Eintrag in `company_user`. +- Relevanz im Admin: wichtig für Rechte im Customer-Profil und für klare Verantwortlichkeit. + +`companies` + +- Kardinalität: n:m. +- Pivot: `company_user` mit `company_id`, `user_id`, `role`. +- Rollen: `member`, `responsible`, `owner`. +- Bedeutung: Ein User kann mehrere Firmen haben; eine Firma kann mehreren Usern zugeordnet sein. +- Relevanz im Admin: Hauptstruktur für Kundenkonto, Berechtigungen in der Firmenpflege und spätere User-Detailansicht. + +`contacts` + +- Kardinalität: n:m. +- Pivot: `contact_user` mit `contact_id`, `user_id`. +- Zusätzlich gehört jeder Kontakt zwingend zu genau einer Firma über `contacts.company_id`. +- Bedeutung: Kontakte können direkt Usern zugeordnet sein, fachlich sind sie aber an Firmen aufgehängt. +- Relevanz im Admin: Ansprechpartner und Zuordnungsqualität. + +`pressReleases` + +- Kardinalität: 1:n. +- Tabelle: `press_releases`, Foreign Key `user_id`. +- Zusätzlich: optionale Firmenzuordnung über `company_id` und Pflichtkategorie über `category_id`. +- Wichtig: Datenbankseitig ist `company_id` nullable, fachlich sollte eine Customer-PM einer Firma zugeordnet sein. Der Customer-Flow erzwingt aktuell eine eigene Firma und leitet daraus das Portal ab. +- Relevanz im Admin: Content-Historie, Status, Veröffentlichungen, Qualität, Freigabeprozess. + +`newsletterSubscriptions` + +- Kardinalität: 1:n. +- Tabelle: `newsletter_subscriptions`, Foreign Key `user_id`. +- Inhalt: Portal, Name, E-Mail, IP, Bestätigung, Subscribe/Unsubscribe, Legacy-Zuordnung. +- Relevanz im Admin: Kommunikationsstatus und Opt-in/Opt-out-Historie. + +`billing / payments` + +- `userPaymentOptions`: 1:n über `user_payment_options.user_id`. +- `invoices`: 1:n über `invoices.user_id`. +- `legacyInvoices`: 1:n über `legacy_invoices.user_id`. +- Indirekt: `user_payment_options` hat n:m zu Firmen über `user_payment_option_company`; `user_payments` hängt an `user_payment_options`; `invoices` können zusätzlich an `user_payments` und `invoice_billing_addresses` hängen. +- Relevanz im Admin: Vertrags-/Abo-Status, aktive API-Freischaltung, Rechnungen, Legacy-Archiv. + +`tokens` + +- Kommt über Laravel Sanctum `HasApiTokens`. +- Tabelle: `personal_access_tokens` mit polymorphem `tokenable_type` / `tokenable_id`. +- Im Customer-Bereich werden Tokens für API-Zugriffe verwaltet. +- Relevanz im Admin: API-Zugang, genutzte Berechtigungen, letzter Zugriff. + +`magicLinks` + +- Kardinalität: 1:n. +- Tabelle: `magic_links`, Foreign Key `user_id`. +- Inhalt: Token-Hash, Zweck, Payload, Ablauf, Verbrauch, IPs. +- Relevanz im Admin: Auth-/Support-Kontext, Magic-Login-Historie. + +`filterPresets` + +- Kardinalität: 1:n. +- Tabelle: `user_filter_presets`, Foreign Key `user_id`. +- Inhalt: Seite, Name, Default-Flag, letzte Nutzung, Filter-JSON. +- Relevanz im Admin: eher Arbeitskomfort/Personalisierung, nicht Kern-Kundendaten. + +`apiUsageLogs` + +- Kardinalität: faktisch 1:n über `api_usage_logs.user_id`, aber aktuell keine Relation im `User`-Model. +- Inhalt: Token, Methode, Pfad, Route, Status, IP, User-Agent, Dauer, Zeitpunkt. +- Relevanz im Admin: API-Aktivität, Support und Missbrauchsanalyse. + +`roles` und `permissions` + +- Kommen über Spatie `HasRoles`. +- Tabellen u. a. `roles`, `permissions`, `model_has_roles`, `model_has_permissions`, `role_has_permissions`. +- Relevanz im Admin: Trennung zwischen Admin, Editor, Customer, Super-Admin und feineren Berechtigungen wie `press-releases:publish` oder `users:manage`. + +## Firmen, Kontakte und Pressemitteilungen + +Die fachliche CRM-/Content-Struktur läuft im Kern so: + +- User hat mehrere Firmen über `company_user`. +- User kann zusätzlich direkter Eigentümer einer Firma über `companies.owner_user_id` sein. +- Firma hat mehrere Kontakte über `contacts.company_id`. +- Firma hat mehrere Pressemitteilungen über `press_releases.company_id`. +- Pressemitteilung hat genau einen Autor/User über `press_releases.user_id`. +- Pressemitteilung kann mehrere Kontakte über `press_release_contact` referenzieren. +- Kontakt kann mehreren Usern direkt zugeordnet sein über `contact_user`, bleibt aber immer an genau eine Firma gebunden. + +Daraus ergibt sich für den User-Admin eine wichtige Sicht: + +- User-Zentrum: Wer ist der Account? +- Firmen-Zentrum: Welche Firmen gehören dazu, in welcher Rolle? +- Kontakt-Zentrum: Welche Ansprechpartner hängen an diesen Firmen und/oder am User? +- Content-Zentrum: Welche Pressemitteilungen wurden vom User erstellt und welcher Firma sind sie zugeordnet? +- Abrechnungs-Zentrum: Welche Zahloptionen, Rechnungen und Legacy-Rechnungen hängen am User und ggf. an Firmen? + +## Pressemitteilungen im Detail + +Eine Pressemitteilung (`press_releases`) enthält: + +- Identifikation: `id`, `uuid`, `slug`, `legacy_portal`, `legacy_id`. +- Zuordnung: `portal`, `language`, `user_id`, `company_id`, `category_id`. +- Inhalt: `title`, `subtitle`, `text`, `backlink_url`, `keywords`, `boilerplate_override`. +- Status/Publikation: `status`, `published_at`, `scheduled_at`, `embargo_at`, `no_export`, `hits`, `teaser_begin`, `teaser_end`. +- Medien: Bilder über `press_release_images`, Anhänge über `press_release_attachments` (UI deaktiviert). +- Kontakte: Pivot `press_release_contact`. +- Verlauf: Statuslogs über `press_release_status_logs`, inklusive `changed_by_user_id`, Statuswechsel, Grund und Quelle. + +Service-Layer rund um PMs: + +- `App\Services\PressRelease\PressReleaseService` — kapselt die Übergänge `submitForReview`, `publish`, `reject`, `archive`, `deleteFromAdmin` sowie das Auflösen von `scheduled_at`/`embargo_at` für die echte Veröffentlichung. +- `App\Services\PressRelease\PressReleaseHtmlSanitizer` — Wrapper um `mews/purifier` mit definierter Tag-/Attribut-Whitelist. +- `App\Services\PressRelease\PressReleaseAttachmentStorage` — kapselt Datei-Operationen auf dem `public`-Disk (UI aktuell deaktiviert). +- `App\Console\Commands\PublishScheduledPressReleases` — wird per Scheduler (alle 5 Min) ausgeführt und veröffentlicht freigegebene PMs zum geplanten Zeitpunkt, respektiert Embargo. + +Fachliche Konsequenz: + +- Für Kunden sollte `company_id` praktisch Pflicht sein, obwohl die DB es nullable erlaubt. +- Für Admins kann `company_id = null` als Migrations-/Altfall auftauchen und sollte im User-Admin sichtbar markiert werden. +- `portal` sollte mit der Firma konsistent sein. Im Customer-Flow wird das Portal aus der Firma abgeleitet; im Admin-Bereich sollte eine bewusste Korrektur möglich sein. + +## User-relevante Tabellen + +Kernkonto: + +- `users` +- `profiles` +- `billing_addresses` +- `sessions` +- `password_reset_tokens` +- `personal_access_tokens` +- `magic_links` +- Spatie Permission-Tabellen + +CRM: + +- `companies` +- `company_user` +- `contacts` +- `contact_user` + +Content: + +- `press_releases` +- `press_release_images` +- `press_release_attachments` (UI temporaer deaktiviert) +- `press_release_contact` +- `press_release_status_logs` +- `categories` +- `category_translations` + +Billing: + +- `billing_addresses` +- `user_payment_options` +- `user_payment_option_company` +- `user_payments` +- `payment_options` +- `payment_option_translations` +- `invoices` +- `invoice_billing_addresses` +- `legacy_invoices` + +Kommunikation/API/Arbeitskomfort: + +- `newsletter_subscriptions` +- `api_usage_logs` +- `user_filter_presets` + +## Relevante Inhalte für den Admin-User-Bereich + +Für eine optimierte Admin-User-Ansicht sollten diese Blöcke geplant werden: + +- Account: Status, Portal, Sprache, Registrierungstyp, Rollen, Berechtigungen, Super-Admin, letzte Aktivität, letzte IP, E-Mail-Verifikation, 2FA-Status. +- Datenqualität: Profil vorhanden, Rechnungsadresse vorhanden, Firma vorhanden, Kontakte vorhanden, veröffentlichte PMs vorhanden, Legacy-Zuordnung vorhanden. +- Firmen: Firmenliste mit Rolle, Eigentümerstatus, Portal, Aktivstatus, Logo, Footer-Code-Flag, Kontakt-/PM-Anzahl. +- Kontakte: Kontakte je Firma plus direkte User-Kontakte aus `contact_user`. +- Pressemitteilungen: Gesamtzahl, Statusverteilung, letzte PMs, veröffentlichte PMs, PMs ohne Firma, PMs mit Portalabweichung. +- Abrechnung: Rechnungsadresse, aktive Zahlungsoptionen, verknüpfte Firmen zu Zahlungsoptionen, Zahlungen, Rechnungen, Legacy-Rechnungen. +- API: Token-Anzahl, Berechtigungen, letzte Nutzung, API-Usage-Logs. +- Support/Admin-Aktionen: User bearbeiten, Rollen/Berechtigungen ändern, Impersonation, Magic-Link-Kontext, ggf. Account deaktivieren. + +## Offene Punkte für die weitere Konzeption + +- `User` hat keine direkte `apiUsageLogs()`-Relation, obwohl `api_usage_logs.user_id` existiert. Für Admin-Detailansichten wäre eine Relation sinnvoll. +- `press_releases.company_id` ist nullable. Fachlich sollte geklärt werden, ob das nur Legacy-/Admin-Fälle erlaubt oder langfristig verboten werden soll. +- `contacts` gehören immer zu einer Firma, können aber zusätzlich direkt Usern zugeordnet werden. Für die Oberfläche muss klar sein, ob "User-Kontakte" nur direkte Pivot-Kontakte meint oder alle Kontakte seiner Firmen. +- Eine Firma kann über `owner_user_id` und zusätzlich über `company_user.role = owner` eine Eigentümerinformation haben. Das sollte im Admin eindeutig dargestellt und ggf. harmonisiert werden. +- Billing hängt primär am User, Zahlungsoptionen können aber über Pivot auch Firmen betreffen. Für den Admin sollte klar werden, ob Rechnungen immer User-Rechnungen sind oder später firmenscharf betrachtet werden sollen. +- Portal-Scope ist auf Firmen, Kontakte, Pressemitteilungen und Newsletter aktiv. Admin-Auswertungen nutzen teils `withoutGlobalScopes()`. In der User-Admin-Konzeption muss entschieden werden, wann portalübergreifend gesucht und angezeigt wird. + +## Quellen im Code + +Modelle: + +- `app/Models/User.php` +- `app/Models/Company.php` +- `app/Models/Contact.php` +- `app/Models/PressRelease.php` +- `app/Models/PressReleaseImage.php` +- `app/Models/PressReleaseAttachment.php` +- `app/Models/PressReleaseStatusLog.php` +- `app/Models/Profile.php` +- `app/Models/BillingAddress.php` +- `app/Models/Invoice.php` +- `app/Models/LegacyInvoice.php` +- `app/Models/UserPaymentOption.php` +- `app/Models/UserPayment.php` +- `app/Models/NewsletterSubscription.php` +- `app/Models/MagicLink.php` +- `app/Models/UserFilterPreset.php` +- `app/Models/ApiUsageLog.php` + +Services / Commands: + +- `app/Services/PressRelease/PressReleaseService.php` +- `app/Services/PressRelease/PressReleaseHtmlSanitizer.php` +- `app/Services/PressRelease/PressReleaseAttachmentStorage.php` +- `app/Services/Image/ImageService.php` +- `app/Console/Commands/PublishScheduledPressReleases.php` +- `routes/console.php` (Scheduler-Eintraege) + +Migrationen: + +- `database/migrations/*` diff --git a/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md b/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md new file mode 100644 index 0000000..1c85339 --- /dev/null +++ b/docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md @@ -0,0 +1,65 @@ + +**Version:** Juni 2026 **Datum:** 12.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Klärung, ob das Portal doppelte/ähnliche Pressemitteilungen automatisch prüfen muss. Separates Thema, bewusst aus dem Phase-2/Magic-Link-Dokument herausgelöst. + +--- + +## 1. Kontext + +Beim Thema Cross-Portal-Veröffentlichung kam Duplicate-Content als SEO-Risiko auf. Daraus entstand die Folgefrage, ob das Portal doppelte oder ähnliche Pressemitteilungen automatisch erkennen/prüfen sollte. Wichtig: Hier werden zwei **verschiedene** Probleme vermischt, die getrennt zu betrachten sind. + +--- + +## 2. Die zwei Duplicate-Szenarien + +||Szenario A: System-Duplicate|Szenario B: Kunden-Duplicate| +|---|---|---| +|**Ursache**|Cross-Portal-Feature spiegelt dieselbe PM auf zwei Domains|Kunde legt manuell zweimal dasselbe an (zwei Firmen-Einträge, zwei ähnliche PMs)| +|**Wer trägt den Schaden**|das Portal (eigene Domains konkurrieren)|der Kunde selbst| +|**Häufigkeit**|systematisch (bei jedem Cross-Post)|seltener Randfall| +|**Status**|**gelöst durch Weglassen**|**kein aktiver Check**| + +--- + +## 3. Szenario A – gelöst durch Weglassen + +Das ursprüngliche SEO-Duplicate entstand ausschließlich durch das geplante Cross-Portal-Feature (eine PM, vom System auf presseecho.de **und** businessportal24.com gespiegelt). Da dieses Feature **bewusst nicht gebaut wird** (Portale optisch, inhaltlich und systemisch getrennt – siehe Decision-Update Phase 2/Magic-Link, Abschnitt 6), entsteht dieser Duplicate gar nicht erst. + +**Kein automatischer Spiegel = kein automatischer Duplicate.** Es ist hier nichts zu prüfen. + +--- + +## 4. Szenario B – bewusst kein automatisches Duplicate-Checking + +Ein Kunde, der manuell zweimal dasselbe einstellt und zweimal zahlt, ist ein seltener Randfall. Ein automatischer Erkennungsmechanismus steht in keinem Verhältnis: + +- **KI-Ähnlichkeitsprüfung** über alle PMs = laufende Kosten bei _jeder_ Veröffentlichung, für einen Randfall +- **DB-Vergleich** („gleiche Firma doppelt + ähnlicher Text") klingt simpel, ist es nicht: „ähnlich" sauber zu definieren (Schwellwert? gleicher Titel? gleicher erster Absatz?) ist die klassische Fuzzy-Matching-Falle – viel Tuning, trotzdem Fehlalarme +- **Gleiche Firma zweimal angelegt** ist oft **legitim** (verschiedene Abteilungen, verschiedene Pressekontakte) – kein verlässliches Duplicate-Signal + +**Entscheidender Punkt:** Den SEO-Nachteil eines selbst erzeugten Duplikats trägt der Kunde, nicht das Portal. Die Leistung wurde erbracht (zwei Veröffentlichungen, zweimal bezahlt). Der Kunde muss nicht vor seiner eigenen Entscheidung geschützt werden. + +--- + +## 5. Was stattdessen greift (kostenlos, sofort) + +1. **Score deckt den relevanten Teil ab.** Die Red-Flag-/Content-Prüfung läuft ohnehin bei jeder PM. Plump kopierte Massen-/Müll-PMs fallen dort als Qualitätsproblem auf – das ist der eigentliche Spam-Filter, kein separates Duplicate-System nötig. +2. **Canonical-Tags sauber setzen.** Rein technisch, keine laufenden Kosten. Jede PM erhält ihre eigene kanonische URL – saubere SEO-Hygiene statt aktivem Prüfprozess. +3. **Phase-3-Option, nur falls es real wird.** Sollte sich im Betrieb zeigen, dass Duplikate tatsächlich ein Problem sind (unwahrscheinlich), lässt sich ein billiger **Hash-/Shingle-Vergleich** auf Titel + erste Absätze nachrüsten – algorithmisch, keine KI. Erst bauen, wenn Daten zeigen, dass es vorkommt. + +--- + +## 6. Entscheidung + +- **Kein automatisches Duplicate-Checking zum Launch.** +- Szenario A (System-Duplicate) ist durch den Verzicht auf Cross-Portal erledigt. +- Szenario B (Kunden-Duplicate) wird nicht aktiv geprüft; der Score fängt Spam/Müll ab, Canonical-Tags sorgen für technische Hygiene. +- Hash-/Shingle-Vergleich bleibt als **Phase-3-Option** notiert, nur bei nachgewiesenem Bedarf. + +--- + +## 7. Anti-Zombie-Check + +- ✅ Keine Kosten auf Vorrat für ein Phantomproblem +- ✅ Kunde wird nicht für legitime Mehrfachnutzung (zwei Abteilungen, zwei Kontakte) fälschlich blockiert +- ✅ Spam-Schutz läuft über den ohnehin vorhandenen Score, nicht über ein zweites paralleles System +- ✅ Technische SEO-Hygiene (Canonical) statt aktivem, fehleranfälligem Prüfprozess \ No newline at end of file diff --git a/docs/weiteres/Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md b/docs/weiteres/Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md new file mode 100644 index 0000000..0824942 --- /dev/null +++ b/docs/weiteres/Decision-Update Phase-2-Funktionen & Magic-Link-Änderungsprozess.md @@ -0,0 +1,201 @@ + + +**Version:** Juni 2026 (Rev. 2) **Datum:** 12.06.2026 **Status:** Abgestimmt – bereit zur Integration ins Konzept-/Decision-Log **Scope:** Definition von Boost und Veröffentlichungsnachweis (Launch), des Magic-Link-Zugangs- und Änderungsprozesses sowie der Phase-2-Funktionen (Vorab-Prüfung/Redigieren, Prüfzähler, höheres Prüfkontingent, kostenpflichtige Änderungspfade). **Änderungen Rev. 2:** KI bei E/F entfernt (Admin-Panel statt KI-Check); A nutzt Algorithmus-Diff statt KI; Bremsen-/Limit-Spalte für alle Pfade ergänzt. **Änderungen Rev. 3:** A & B als Launch bestätigt; Boost-Preisstaffel (12/20/35) und PDF-Preis (3 Credits) festgelegt; G Depublizieren = 25 Credits. **Änderungen Rev. 4:** Extra-PM ergänzt – tier-gestaffelt (19/15/12/10/8 Credits); Cross-Portal-Veröffentlichung als kein Thema vermerkt (Portale getrennt). + +--- + +## 1. Kontext + +Dieses Update definiert die credit-nahen Funktionen und den Änderungsprozess für bestehende Pressemitteilungen. Leitlinie: kleiner, ehrlicher Launch-Umfang; alles, was an die volle Credit-/Prüf-Mechanik gekoppelt ist, wandert kontrolliert in Phase 2. Gesetzlich verpflichtende Pfade bleiben unabhängig davon ab Launch erreichbar. + +**Grundprinzip Kostenkontrolle:** Jeder Vorgang, der einen KI-Call oder Admin-Arbeit auslöst, braucht ein Limit. Daraus folgen zwei Strategien: + +1. **Kosten vermeiden statt deckeln** – wo ein billiger Algorithmus die Arbeit erledigt (z. B. Tippfehler-Diff), wird kein KI-Call ausgelöst. Das Limit schützt dann nur noch gegen Missbrauch, nicht gegen Kosten. +2. **Bezahlung als Selbstbremse** – kostenpflichtige Pfade (C/D/G) sind selbstbegrenzend: Wer zahlt, missbraucht nicht. Harte Limits braucht es nur bei den kostenlosen Pfaden (A/B/E/F). + +--- + +## 2. Launch-Funktionen + +### 2.1 Extra-Pressemitteilung (Kontingent-Nachkauf) + +Die Brücke zwischen Tarif-Kontingent und Credit-Wallet: Ist das monatliche PM-Kontingent voll, kann eine einzelne weitere PM über die Wallet nachgekauft werden – ohne Zwang zum nächsthöheren Tarif. Das ist die faire Alternative zum erzwungenen Upgrade und der genau gewünschte Fall „ich brauche _einmalig_ eine PM mehr". + +**Preis ist tier-gestaffelt** (Treuevorteil: höheres Abo = günstigere Extra-PM): + +|Situation|Extra-PM|Inkl. PM-Preis|Logik| +|---|---|---|---| +|Kein Abo (Einzel)|19 Credits|19,00 €|voller Satz = Einzel-PM-Preis| +|Starter|15 Credits|9,67 €|günstiger als Einzel, mild über inkludiert| +|Business|12 Credits|4,90 €|–| +|Pro|10 Credits|3,96 €|–| +|Agency|8 Credits|3,32 €|–| + +**Mechanik:** Der Extra-PM-Preis wird **zur Kaufzeit aus dem aktiven Abo abgeleitet**, nicht statisch gespeichert. Reicht das Wallet-Guthaben nicht, greift der kontextuelle Mini-Checkout („Kostet 15 Credits, du hast 8" → Paket nachladen). Der Volumenrabatt steckt bereits in den Credit-Paketen – **keine** separaten Extra-PM-Bündel nötig (eine Wallet, ein tier-abhängiger Preis). + +**Design-Entscheidung (bewusst):** Der prozentuale Aufschlag ggü. dem inkludierten PM-Preis ist bei Starter am mildesten (+55 %), bei den oberen Tiers höher (~140–150 %). Das ist gewollt: Die gelegentliche 4. PM eines Starter-Kunden wird _nicht_ bestraft, um künstlichen Upgrade-Druck zu erzeugen – passt zur „fair bleiben, kein Zwang"-Linie. Wer dauerhaft viel veröffentlicht, wandert ohnehin in höhere Tiers. + +**Tageslimit gilt auch hier:** Nachgekaufte Extra-PMs unterliegen demselben Tageslimit wie das Kontingent (kein „Spam freikaufen"; höherer Tagesdurchsatz = Enterprise-Fall). + +### 2.2 Boost (Platzierung) + +- **Was:** bezahlte Hervorhebung einer PM – Platzierung auf Startseite und Branchen-/Kategorieseite +- **Gate:** nur **grüne** PMs sind boostbar; gelb/rot nicht +- **Mechanik:** nachträglich kaufbar, sobald die PM live und grün ist +- **Umfang:** ein Boost = Featured auf Startseite **und** Branchenseite (eine Stufe, nur die Dauer variiert – bewusst kein getrenntes Pricing pro Platzierung) +- **Bezahlung:** über die Credit-Wallet (kleine Wallet ist Launch-Bestandteil) + +**Preisstaffel (1 Credit = 1 €):** + +|Dauer|Credits|Pro Tag| +|---|---|---| +|7 Tage|12|1,71 €| +|14 Tage|20|1,43 €| +|30 Tage|35|1,17 €| + +Moderate Staffel: Pro-Tag-Preis sinkt mit der Dauer, der Einstieg (7 Tage / 12) bleibt unter dem PM-Preis von 19 – ein Boost wirkt nie teurer als das Veröffentlichen selbst. Passt zur „Nische besetzen statt abschöpfen"-Linie (Volumen statt Einzelmarge). + +### 2.3 Veröffentlichungsnachweis / PDF + +- **Was:** generiertes PDF „PM XY wurde am … auf … veröffentlicht" inkl. URL, Datum, Vorschau +- **Zweck:** Reporting an Vorgesetzte/Kunden – klassische PR-Mitnahme +- **Preis:** **3 Credits**, pauschal pro PM – Impulskauf, keine Abwägung (kostet die Plattform praktisch nichts: keine KI, on-demand aus vorhandenen Daten) +- **Generierung:** on-demand aus vorhandenen PM-Daten +- **Phase-2-Option:** in Business/Pro/Agency später ggf. **inklusive** als kleiner Tarif-Perk; zum Launch einheitlich 3 Credits + +--- + +## 3. Magic-Link: Zugang & Änderungsprozess + +### 3.1 Zugangsmodell – eine Verwaltung, zwei Eintrittswege + +**Grundsatz:** Die Verwaltung bestehender Pressemitteilungen ist **ein und dieselbe** Funktion im pressekonto. Es gibt nur zwei Wege hinein: + +1. **Registrierter Account** → normaler Login → verwaltet seine PMs direkt im pressekonto. +2. **Pressekontakt ohne Account** → **Magic-Link** → loggt sich zu den PMs ein, die mit seiner hinterlegten E-Mail verknüpft sind, und nimmt dort Änderungen vor. + +Der Magic-Link ist also **kein eigenes System**, sondern die Zugangsbrücke für Pressekontakte bzw. Firmen, deren E-Mail-Adressen im System hinterlegt sind, die aber noch nicht direkt registriert sind. Die dahinterliegende Verwaltungsoberfläche und die Änderungspfade sind identisch zum eingeloggten Account. + +**Account-Konvertierung:** Aus dem Magic-Link-Zugang heraus kann der Pressekontakt jederzeit „Permanenten Account anlegen" wählen (Passwort vergeben). Danach läuft der Zugang über den regulären Login – der Magic-Link wird für ihn überflüssig. + +**Authentifizierung (Sicherheit):** + +- Auf jeder PM dezenter Link „Sie sind als Pressekontakt hinterlegt? Pressemitteilung verwalten →" +- E-Mail-Eingabe + Captcha; **identische Antwort unabhängig vom Match** (verhindert User-Enumeration) +- Bei Match: Magic-Link-Mail mit 30-Min-Token → authentifizierte Session +- Dashboard listet alle PMs mit dieser E-Mail als Pressekontakt (über mehrere Firmen/Jahre) + +### 3.2 Änderungs- & Lösch-Pfade (A–G) + +Kein wahlloses Ändern/Löschen – Friction nach Anliegen. Alle Pfade laufen über dieselbe Verwaltung (Login **oder** Magic-Link). + +|Pfad|Anliegen|KI / Admin|Kosten|Bremse|Phase|Public Hint| +|---|---|---|---|---|---|---| +|**A**|Tippfehler/Grammatik|Algorithmus-Diff, KI nur im Graubereich|kostenfrei|1 Einreichung/PM/24 h, gebündelt + Account-Tages-Cap|**Launch**|nein| +|**B**|Pressekontakt-Daten ändern|keine|kostenfrei|normales Rate-Limit|**Launch**|nein| +|**C**|Inhaltliche Korrektur (sachlicher Fehler)|KI|kostenpflichtig|Bezahlung = Bremse|**Phase 2**|ja| +|**D**|Update/Ergänzung (neue Information)|KI|kostenpflichtig|Bezahlung = Bremse|**Phase 2**|ja| +|**E**|DSGVO-Anonymisierung|**Admin-Panel** (keine KI)|kostenfrei|1 offene Anfrage/PM gleichzeitig|**Launch (Pflicht)**|nein| +|**F**|Persönlichkeitsrechtsverletzung|**Admin-Panel** (keine KI)|kostenfrei|1 offene Anfrage/PM gleichzeitig|**Launch (Pflicht)**|je nach Outcome| +|**G**|Depublizieren|KI|25 Credits + Bedenkzeit|Bezahlung + 24–48 h Bedenkzeit|**Phase 2**|Tombstone| + +A & B sind als Launch-Bestandteil bestätigt: kostenfrei, credit-unabhängig und sie senken sofort das unautorisierte Mail-Aufkommen. C/D/G folgen mit dem Credit-/Prüf-System in Phase 2. + +**Pfad-Details (Kurzfassung):** + +- **A Tippfehler:** Inline-Editor mit Diff. **Kein KI-Call im Normalfall** – ein lokaler Zeichen-Diff (Levenshtein) entscheidet: Änderung klein **und** keine Zahlen/Namen/Eigennamen berührt → automatisch übernommen (0 KI, 0 Admin). Nur im Graubereich (größere Änderung oder sensible Tokens berührt) → KI-Check **oder** Umleitung zu Pfad C. Nutzer korrigiert idealerweise alle Tippfehler gebündelt und reicht **einmal** ein = ein Vorgang. +- **B Kontaktdaten:** Formular, direkt übernommen, Versionierung im Hintergrund +- **C Korrektur:** Editor + Pflichtfeld „Was war falsch / was ist korrekt?"; KI erlaubt Fakt-Korrektur (Zahl/Datum/Name), blockiert Umschreibung der Aussage; PM erhält Korrektur-Hinweis +- **D Update:** Ergänzung wird unten mit Datum angehängt, Original bleibt unverändert; KI-Check auf Spam/Werbung +- **E DSGVO:** Aufklärung (Art. 85 DSGVO, Medienprivileg → keine Volllöschung, aber kostenfreie Entfernung personenbezogener Daten); Checkbox-Auswahl (Name, Durchwahl, persönliche E-Mail, Freitext). **Keine KI** – die Anfrage geht als Benachrichtigung ins Admin-Panel, manuelle Sichtung. In Phase 2/3 automatisierbar. +- **F Persönlichkeitsrecht:** Pflichtfelder (betroffene Stelle, Art, Begründung, Belege). **Keine KI** – Benachrichtigung ins Admin-Panel, manuelle Sichtung; Outcomes: Anonymisierung, Anpassung, Tombstone, Ablehnung mit Begründung. In Phase 2/3 automatisierbar. +- **G Depublizieren:** Aufklärungsseite → Begründungspflicht (KI lenkt „veraltet" → D, „falsch/peinlich" → C) → kostenpflichtige Bestätigung → 24–48 h Bedenkzeit mit Widerrufslink → Tombstone (`noindex`, raus aus Listen/Suche, URL bleibt) + +### 3.3 Compliance-Minimum zum Launch (E & F) + +**E und F können nicht auf Phase 2 warten.** Sobald PMs live sind, besteht ein gesetzliches Recht auf Anonymisierung personenbezogener Daten (E) und auf Meldung von Persönlichkeitsrechtsverletzungen (F). Beide müssen ab Tag 1 erreichbar sein – zur Not als einfaches Formular/manueller Prozess statt vollem Wizard. Beide sind **kostenfrei** (eine legitime Rechtsanfrage darf nie hinter einer Gebühr stehen) und damit unabhängig vom Phase-2-Credit-Build. + +**Abwicklung über Admin-Panel (keine KI):** E und F lösen keinen KI-Call aus. Die Anfrage erzeugt eine Benachrichtigung im Admin-Panel, die Sichtung erfolgt manuell. Das spart KI-Kosten und gibt bei Rechtsthemen die bessere Kontrolle. Eine Automatisierung ist für Phase 2/3 vorgesehen. + +**Limit – nicht „einmalig", sondern „eine offene Anfrage pro PM":** Eine gesetzliche Anfrage darf nie hart gesperrt werden (jemand kann legitim ein zweites Mal eine Anonymisierung brauchen, etwa wenn über ein Update neue personenbezogene Daten hinzukommen). Die saubere Bremse: Solange für eine PM eine Anfrage offen ist, kann keine neue gestellt werden (verhindert Spam-Duplikate); nach Abschluss geht wieder eine. Das deckelt Missbrauch ohne Rechtsrisiko. + +### 3.4 Missbrauchsschutz, Edge Cases & Standard-Antwort + +- **Pfad A (Tippfehler):** Algorithmus-Diff spart im Normalfall jeden KI-Call → Limit schützt nur gegen Missbrauch, nicht gegen Kosten. Bremse: 1 Korrektur-Einreichung pro PM / 24 h (gebündelt), plus Account-Tages-Cap gegen den pathologischen Fall (ein Account bearbeitet hunderte PMs) +- **Rate-Limit** auf Magic-Link-Anfragen pro E-Mail/IP; Cooldown nach Depublizierung (Widerruf-Fenster); Audit-Log mit IP/User-Agent je Edit-Aktion +- **Keine valide E-Mail** (alte connektar-PMs): Fallback „Verifikation per Domain-Inhaberschaft / Impressums-Match", manuelle Prüfung +- **E-Mail geändert / Person verlässt Firma:** manuelle Anfrage mit Bestätigung über `info@`/Impressum +- **Massenanträge:** Bulk möglich, Friction (Gebühr, Bedenkzeit) wird **pro PM** angewendet +- **Standard-Antwort auf unautorisierte Lösch-Mails:** „Änderungen an Pressemitteilungen sind ausschließlich über das Verwaltungs-Portal der jeweiligen PM möglich." → beendet das wahllose „löscht das mal eben"-Mailaufkommen ab Launch + +### 3.5 Abgrenzung zum öffentlichen „Melden"-Button + +||„Melden" (öffentlich)|Änderungs-Flow (autorisiert)| +|---|---|---| +|Wer|jeder Dritte|nur Pressekontakt (Login/Magic-Link)| +|Auth|keine|Login oder Magic-Link| +|Use Case|Urheberrecht, Verleumdung, Spam|eigene PM ändern| +|Outcome|Quarantäne, Prüfung|siehe Pfade A–G| + +Treffpunkt: Eine über „Melden" angezeigte Persönlichkeitsrechtsverletzung läuft in dieselbe Queue wie Pfad F. + +--- + +## 4. Phase 2 (nachgelagert) + +Diese Funktionen entstehen gemeinsam, weil sie alle die Re-Check-/Credit-Mechanik voraussetzen. + +### 4.1 Vorab-Prüfung / Redigieren + +Erzeugt erst die Situation „prüfen, ohne (noch) zu veröffentlichen" und den Edit→Neu-Prüfen-Loop. Damit werden auch die kostenpflichtigen Pfade C/D technisch real. + +### 4.2 Prüfzähler + +- **Eigener Zähler**, getrennt von der Credit-Wallet → „Prüfungen inklusive" bleibt sauberes Versprechen +- **Aggregiert pro Account/Monat** gedeckelt (nicht pro PM) → löst Klon-Abuse ohne Klon-Erkennung +- **Prüf-Tageslimit** als Burst-Schutz (~10/Tag) +- **Overflow:** Zähler leer → weitere Prüfungen ziehen 1 Credit/Prüfung aus der Wallet + +### 4.3 Höheres Prüfkontingent (Tier-gestaffelt) + +|Tier|Freie Prüfungen/Monat| +|---|---| +|Einzel|4| +|Starter|12| +|Business|30| +|Pro|60| +|Agency|120| + +Schnitt ~3–4 Prüfungen pro inkludierter PM; ehrlicher Normalfall (≈2 Prüfungen) stößt nie an. Dies sind die früheren „Bonus-Credit"-Zahlen, umgewidmet zum Prüf-Kontingent. + +### 4.4 Kostenpflichtige Magic-Link-Pfade (C / D / G) + +Kosten-Anker bei 1 Credit = 1 € (zu bestätigen): + +|Pfad|Aktion|Anker| +|---|---|---| +|C|Inhaltliche Korrektur|≈ 8 Credits (zu bestätigen)| +|D|Update/Ergänzung|≈ 4 Credits (zu bestätigen)| +|G|Depublizieren|**25 Credits** + 24–48 h Bedenkzeit (festgelegt)| + +Depublizieren bewusst am teuersten und mit Bedenkzeit, weil irreversibelste Aktion. + +--- + +## 5. Anti-Zombie-Check (dieser Stand) + +- ✅ Gesetzliche Anfragen (E/F) immer kostenfrei, ab Launch erreichbar und nie hart gesperrt (1 offene Anfrage/PM statt „einmalig") +- ✅ Kosten nur bei echtem Mehraufwand (Korrektur, Update, Depublizierung), nicht bei Pflicht-Rechten +- ✅ Kostenlose Pfade bleiben echt kostenlos: Tippfehler laufen über Algorithmus-Diff statt KI, Limits schützen gegen Missbrauch – nicht als versteckte Kostenbremse +- ✅ Eine Verwaltung, zwei Eintrittswege – keine künstliche Trennung registrierter/unregistrierter Nutzer +- ✅ Prüf-Kontingent großzügig genug, dass der Normalfall nie ansteht +- ✅ Depublizierung mit Aufklärung + Bedenkzeit statt Hard-Delete – schützt den Kunden vor sich selbst + +--- + + + +**Bewusst nicht im Scope – Cross-Portal-Veröffentlichung:** Die beiden Portale (presseecho.de, businessportal24.com) sind optisch, inhaltlich und systemisch vollständig getrennt; im Relaunch laufen sie lediglich auf einem gemeinsamen Backend zusammen. Eine PM auf beiden Portalen gleichzeitig zu veröffentlichen ist **kein Feature** – wer in beiden präsent sein will, bucht zwei getrennte Einträge (zwei Slots / zwei Extra-PMs). Das vermeidet Duplicate-Content über die eigenen Domains und passt zur getrennten Zielgruppen-Logik. Kein Phase-2-Punkt, sondern bewusst ausgeschlossen. +Noch mal ein wichtiger Hintergrund, der noch dokumentiert werden muss. Das sollte auch zukünftig gegebenenfalls geprüft werden. + +**In Rev. 3 abgeschlossen:** A/B-Launch ✓ · Boost-Staffel 12/20/35 ✓ · PDF 3 Credits ✓ · G Depublizieren 25 Credits ✓ **In Rev. 4 abgeschlossen:** Extra-PM tier-gestaffelt (19/15/12/10/8) ✓ · Cross-Portal ausgeschlossen ✓ \ No newline at end of file diff --git a/package.json b/package.json index 7dfd43f..41b0371 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "type": "module", "scripts": { - "dev": "echo 'Bitte spezifischen Dev-Server starten: npm run dev:portal ODER npm run dev:web ODER npm run dev:all'", + "dev": "echo 'Dev-Server: npm run dev:portal (Admin/FluxUI) | npm run dev:web (pressekonto Hub + presseecho + businessportal24) | npm run dev:all (beides)'", "dev:portal": "vite --config vite.portal.config.js", "dev:web": "vite --config vite.web.config.js", "dev:all": "concurrently \"npm run dev:portal\" \"npm run dev:web\" --names \"PORTAL,WEB\" --prefix-colors \"cyan,magenta\"", diff --git a/phpunit.xml b/phpunit.xml index 21f22e5..23a6bc1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,9 @@ + + + diff --git a/public/images/press-release-placeholders/01-grid-blue.svg b/public/images/press-release-placeholders/01-grid-blue.svg new file mode 100644 index 0000000..dfb7ba7 --- /dev/null +++ b/public/images/press-release-placeholders/01-grid-blue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/02-grid-green.svg b/public/images/press-release-placeholders/02-grid-green.svg new file mode 100644 index 0000000..d48e5f8 --- /dev/null +++ b/public/images/press-release-placeholders/02-grid-green.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/03-grid-amber.svg b/public/images/press-release-placeholders/03-grid-amber.svg new file mode 100644 index 0000000..9344eba --- /dev/null +++ b/public/images/press-release-placeholders/03-grid-amber.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/04-lines-blue.svg b/public/images/press-release-placeholders/04-lines-blue.svg new file mode 100644 index 0000000..2b9dcfa --- /dev/null +++ b/public/images/press-release-placeholders/04-lines-blue.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/05-lines-green.svg b/public/images/press-release-placeholders/05-lines-green.svg new file mode 100644 index 0000000..29a1401 --- /dev/null +++ b/public/images/press-release-placeholders/05-lines-green.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/06-lines-amber.svg b/public/images/press-release-placeholders/06-lines-amber.svg new file mode 100644 index 0000000..bfd7619 --- /dev/null +++ b/public/images/press-release-placeholders/06-lines-amber.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/07-dots-blue.svg b/public/images/press-release-placeholders/07-dots-blue.svg new file mode 100644 index 0000000..90d4e48 --- /dev/null +++ b/public/images/press-release-placeholders/07-dots-blue.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/08-dots-green.svg b/public/images/press-release-placeholders/08-dots-green.svg new file mode 100644 index 0000000..58b8f2c --- /dev/null +++ b/public/images/press-release-placeholders/08-dots-green.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/09-dots-amber.svg b/public/images/press-release-placeholders/09-dots-amber.svg new file mode 100644 index 0000000..65d776d --- /dev/null +++ b/public/images/press-release-placeholders/09-dots-amber.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/10-waves-blue.svg b/public/images/press-release-placeholders/10-waves-blue.svg new file mode 100644 index 0000000..e1db745 --- /dev/null +++ b/public/images/press-release-placeholders/10-waves-blue.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/11-waves-green.svg b/public/images/press-release-placeholders/11-waves-green.svg new file mode 100644 index 0000000..c7da393 --- /dev/null +++ b/public/images/press-release-placeholders/11-waves-green.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/12-waves-amber.svg b/public/images/press-release-placeholders/12-waves-amber.svg new file mode 100644 index 0000000..ffc2fdd --- /dev/null +++ b/public/images/press-release-placeholders/12-waves-amber.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/13-editorial-blue.svg b/public/images/press-release-placeholders/13-editorial-blue.svg new file mode 100644 index 0000000..e7e9a08 --- /dev/null +++ b/public/images/press-release-placeholders/13-editorial-blue.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/14-editorial-green.svg b/public/images/press-release-placeholders/14-editorial-green.svg new file mode 100644 index 0000000..a08eead --- /dev/null +++ b/public/images/press-release-placeholders/14-editorial-green.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/15-editorial-amber.svg b/public/images/press-release-placeholders/15-editorial-amber.svg new file mode 100644 index 0000000..50bfc85 --- /dev/null +++ b/public/images/press-release-placeholders/15-editorial-amber.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/16-signal-blue.svg b/public/images/press-release-placeholders/16-signal-blue.svg new file mode 100644 index 0000000..69f3342 --- /dev/null +++ b/public/images/press-release-placeholders/16-signal-blue.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/17-signal-green.svg b/public/images/press-release-placeholders/17-signal-green.svg new file mode 100644 index 0000000..63fde4c --- /dev/null +++ b/public/images/press-release-placeholders/17-signal-green.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/press-release-placeholders/18-signal-amber.svg b/public/images/press-release-placeholders/18-signal-amber.svg new file mode 100644 index 0000000..d5e9b67 --- /dev/null +++ b/public/images/press-release-placeholders/18-signal-amber.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/resources/css/shared/hub-components.css b/resources/css/shared/hub-components.css index d299b73..675022d 100644 --- a/resources/css/shared/hub-components.css +++ b/resources/css/shared/hub-components.css @@ -54,6 +54,114 @@ border-bottom-color: rgba(255, 255, 255, 0.1); } + /* ============================================================ + * Page-Header (Titel links, Aktionen rechts) + * + * Ersetzt das frühere Inline-Style `grid-template-columns:1fr auto`: + * Das starre Zwei-Spalten-Grid konnte auf schmalen Screens nicht + * umbrechen — die Aktions-Spalte (auto = Inhaltsbreite) quetschte + * die Titel-Spalte zusammen (Buchstaben-Umbrüche im Titel, Buttons + * liefen aus der Box). Unterhalb von lg stapeln die Bereiche. + * ============================================================ */ + + .page-header { + display: grid; + align-items: end; + gap: 20px; + } + @media (min-width: 1024px) { + .page-header { + grid-template-columns: 1fr auto; + gap: 32px; + } + } + /* Aktions-Zeilen (Buttons rechts) dürfen auf schmalen Screens + umbrechen, statt aus dem Header zu laufen. */ + .page-header > .flex { + flex-wrap: wrap; + } + + /* ============================================================ + * PM-Inhalt (gerenderter Editor-HTML in Vorschau/Detail) + * + * Die Detailseiten nutzten `prose`-Klassen, aber das + * Tailwind-Typography-Plugin ist nicht installiert — der Text + * erschien deshalb unformatiert. Diese Klasse bildet die + * Editor-Typografie nach (Absätze, Listen, Zwischenüberschriften, + * Links), ohne eine neue Abhängigkeit einzuführen. + * ============================================================ */ + + .pr-content { + font-size: 14.5px; + line-height: 1.7; + color: var(--color-ink); + } + .pr-content p { + margin: 0 0 1em; + } + .pr-content p:last-child { + margin-bottom: 0; + } + .pr-content h2, + .pr-content h3, + .pr-content h4 { + color: var(--color-ink); + font-weight: 700; + letter-spacing: -0.2px; + line-height: 1.3; + margin: 1.6em 0 0.6em; + } + .pr-content h2 { + font-size: 19px; + } + .pr-content h3 { + font-size: 16.5px; + } + .pr-content h4 { + font-size: 15px; + } + .pr-content h2:first-child, + .pr-content h3:first-child, + .pr-content h4:first-child { + margin-top: 0; + } + .pr-content ul, + .pr-content ol { + margin: 0 0 1em; + padding-inline-start: 1.4em; + } + .pr-content ul { + list-style: disc; + } + .pr-content ol { + list-style: decimal; + } + .pr-content li { + margin: 0.3em 0; + } + .pr-content a { + color: var(--color-hub); + text-decoration: underline; + text-underline-offset: 2px; + } + .pr-content strong, + .pr-content b { + font-weight: 700; + color: var(--color-ink); + } + .pr-content blockquote { + margin: 1em 0; + padding: 0.2em 0 0.2em 1em; + border-left: 3px solid var(--color-hub-soft-2); + color: var(--color-ink-2); + font-style: italic; + } + .pr-content hr { + margin: 1.5em 0; + border: 0; + border-top: 1px solid var(--color-bg-rule); + } + /* ============================================================ * Stat-Cards (KPI-Karten mit farbigem Strip links) * ============================================================ */ @@ -117,8 +225,15 @@ font-weight: 600; color: var(--color-ink); letter-spacing: -0.5px; - line-height: 1; + line-height: 1.05; margin-top: 14px; + /* Text-Werte (z. B. Portal-Name) dürfen die Karte nie sprengen. */ + overflow-wrap: anywhere; + } + @media (max-width: 480px) { + .stat-num { + font-size: 26px; + } } .stat-card.is-ok .stat-num { color: var(--color-ok); @@ -1151,6 +1266,31 @@ * Tag-Chips und Portal-/Veröffentlichungs-Optionen verwendet. */ @layer components { + /* Container-Query-Kontext: Das zweispaltige Editor-Layout richtet sich + nach dem real verfügbaren Inhaltsbereich, nicht nach dem Viewport. + So rutschen die rechten Cards automatisch nach unten, sobald die + Sidebar offen ist und der Platz knapp wird — unabhängig davon, bei + welcher Viewport-Breite die Sidebar gerade ein- oder ausfährt. */ + .pr-editor-shell { + container-type: inline-size; + container-name: pr-editor; + } + + .pr-editor-layout { + grid-template-columns: minmax(0, 1fr); + } + + @container pr-editor (min-width: 960px) { + .pr-editor-layout { + grid-template-columns: minmax(0, 1fr) 360px; + } + + .pr-editor-side { + position: sticky; + top: 1rem; + } + } + .pr-form-label { display: flex; align-items: center; diff --git a/resources/views/admin/companies/create.blade.php b/resources/views/admin/companies/create.blade.php index 19dd1b5..7f2b8cc 100644 --- a/resources/views/admin/companies/create.blade.php +++ b/resources/views/admin/companies/create.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/companies/edit.blade.php b/resources/views/admin/companies/edit.blade.php index 4defcdc..a53344b 100644 --- a/resources/views/admin/companies/edit.blade.php +++ b/resources/views/admin/companies/edit.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/companies/show.blade.php b/resources/views/admin/companies/show.blade.php index a986b05..5fddf82 100644 --- a/resources/views/admin/companies/show.blade.php +++ b/resources/views/admin/companies/show.blade.php @@ -1,7 +1,7 @@
- + {{ __('Zurück zur Übersicht') }}
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index ec6e08d..c03c97c 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -2,7 +2,7 @@
{{-- ============== PAGE HEADER ============== --}} -
+