diff --git a/app/Console/Commands/BackfillChangelog.php b/app/Console/Commands/BackfillChangelog.php new file mode 100644 index 00000000..6f4ed3d2 --- /dev/null +++ b/app/Console/Commands/BackfillChangelog.php @@ -0,0 +1,54 @@ + $rows */ + $rows = require database_path('seeders/data/changelog-backfill.php'); + $created = 0; + + foreach ($rows as $row) { + $key = 'backfill:'.sha1($row['date'].'|'.$row['category'].'|'.$row['text']); + + if (ChangelogEntry::query()->where('source_key', $key)->exists()) { + continue; + } + + $created++; + + if ($this->option('dry-run')) { + continue; + } + + ChangelogEntry::query()->create([ + 'released_on' => $row['date'], + 'release_tag' => $row['tag'], + 'category' => ChangelogCategory::from($row['category']), + 'text' => $row['text'], + 'status' => ChangelogStatus::Draft, + 'source_key' => $key, + 'review_note' => 'Nachtrag aus Protokoll und Roadmap — Datum und Text prüfen.', + ]); + } + + $this->info(sprintf('%s%d von %d Einträgen nachgetragen (Entwurf). Freigabe im Admin ▸ Changelog.', $this->option('dry-run') ? '[Dry-Run] ' : '', $created, count($rows))); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CollectChangelog.php b/app/Console/Commands/CollectChangelog.php new file mode 100644 index 00000000..5493d64e --- /dev/null +++ b/app/Console/Commands/CollectChangelog.php @@ -0,0 +1,61 @@ +collect( + (string) $this->argument('from'), + (string) $this->argument('to'), + $this->option('date') !== null ? Carbon::parse((string) $this->option('date')) : null, + (bool) $this->option('dry-run'), + ); + } catch (Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + '%s%d neue Entwürfe, %d schon vorhanden, %d Commits ohne Changelog-Zeile (intern).', + $this->option('dry-run') ? '[Dry-Run] ' : '', + $result['created'], + $result['existing'], + $result['internal'], + )); + + if ($result['entries'] !== []) { + $this->table( + ['Rubrik', 'Text', 'Hinweis'], + array_map(static fn ($entry): array => [ + $entry->category->label(), + $entry->text, + $entry->review_note ?? '', + ], $result['entries']), + ); + $this->line(' Freigabe im Admin ▸ Changelog.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/ChangelogCategory.php b/app/Enums/ChangelogCategory.php new file mode 100644 index 00000000..8f8ff48a --- /dev/null +++ b/app/Enums/ChangelogCategory.php @@ -0,0 +1,48 @@ + 'Neu', + self::Improved => 'Verbessert', + self::Fixed => 'Behoben', + self::Security => 'Sicherheit', + }; + } + + /** + * Reihenfolge innerhalb eines Tages. + */ + public function order(): int + { + return match ($this) { + self::New => 1, + self::Improved => 2, + self::Fixed => 3, + self::Security => 4, + }; + } + + /** + * Wert aus der Commit-Zeile `Changelog: …` — Groß-/Kleinschreibung egal. + */ + public static function fromTrailer(string $value): ?self + { + return self::tryFrom(mb_strtolower(trim($value))); + } +} diff --git a/app/Enums/ChangelogStatus.php b/app/Enums/ChangelogStatus.php new file mode 100644 index 00000000..c8ad1d8e --- /dev/null +++ b/app/Enums/ChangelogStatus.php @@ -0,0 +1,28 @@ + 'Entwurf', + self::Published => 'Freigegeben', + self::Discarded => 'Verworfen', + }; + } + + public function badgeColor(): string + { + return match ($this) { + self::Draft => 'amber', + self::Published => 'green', + self::Discarded => 'zinc', + }; + } +} diff --git a/app/Http/Controllers/Web/ChangelogController.php b/app/Http/Controllers/Web/ChangelogController.php new file mode 100644 index 00000000..07c4dbc6 --- /dev/null +++ b/app/Http/Controllers/Web/ChangelogController.php @@ -0,0 +1,81 @@ +onHub($request)) { + return redirect($this->hubBase().'/changelog', 301); + } + + DomainAssetContext::applyWebDomainConfig('pressekonto'); + + return view('web.changelog', [ + 'entries' => $this->timeline->entries(), + 'feedUrl' => $this->hubBase().'/changelog/feed', + ]); + } + + public function feed(Request $request): Response|RedirectResponse + { + if (! $this->onHub($request)) { + return redirect($this->hubBase().'/changelog/feed', 301); + } + + $base = $this->hubBase(); + $entries = $this->timeline->entries(50); + + $items = $entries->map(fn (ChangelogEntry $entry): array => [ + 'title' => $entry->category->label().': '.Str::limit($entry->publicText(), 90), + 'link' => $base.'/changelog#eintrag-'.$entry->id, + 'published_at' => $entry->released_on->copy()->setTime(12, 0)->toRfc2822String(), + 'description' => $entry->publicText(), + 'category' => $entry->category->label(), + 'creator' => null, + ])->all(); + + $xml = view('web.feed', [ + 'channel' => [ + 'title' => 'pressekonto – Changelog', + 'link' => $base.'/changelog', + 'self' => $base.'/changelog/feed', + 'description' => 'Neuerungen auf pressekonto, presseecho und businessportal24.', + 'language' => 'de', + 'last_build' => ($entries->first()?->released_on?->copy()->setTime(12, 0) ?? Carbon::now())->toRfc2822String(), + ], + 'items' => $items, + ])->render(); + + return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + } + + private function onHub(Request $request): bool + { + return str_contains($request->getHost(), 'pressekonto'); + } + + private function hubBase(): string + { + return rtrim((string) config('domains.domain_portal_url'), '/'); + } +} diff --git a/app/Models/ChangelogEntry.php b/app/Models/ChangelogEntry.php new file mode 100644 index 00000000..a63d7c60 --- /dev/null +++ b/app/Models/ChangelogEntry.php @@ -0,0 +1,71 @@ + */ + use HasFactory; + + protected $fillable = [ + 'released_on', + 'release_tag', + 'category', + 'text', + 'status', + 'source_key', + 'commit_subject', + 'review_note', + 'article_worthy', + 'published_at', + 'reviewed_by_user_id', + ]; + + protected function casts(): array + { + return [ + 'released_on' => 'date', + 'category' => ChangelogCategory::class, + 'status' => ChangelogStatus::class, + 'article_worthy' => 'boolean', + 'published_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } + + /** + * @param Builder $query + */ + public function scopePublished(Builder $query): void + { + $query->where('status', ChangelogStatus::Published->value); + } + + /** + * Öffentlicher Text: Sicherheit immer nur als Sammelzeile (E5), egal + * was im Entwurf steht. + */ + public function publicText(): string + { + return $this->category === ChangelogCategory::Security ? ChangelogCategory::SECURITY_LINE : $this->text; + } +} diff --git a/app/Services/Changelog/ChangelogCollector.php b/app/Services/Changelog/ChangelogCollector.php new file mode 100644 index 00000000..8e3a694b --- /dev/null +++ b/app/Services/Changelog/ChangelogCollector.php @@ -0,0 +1,209 @@ +} + */ + public function collect(string $from, string $to, ?Carbon $releasedOn = null, bool $dryRun = false): array + { + $releasedOn ??= Carbon::today(); + $created = 0; + $existing = 0; + $internal = 0; + $entries = []; + $hasSecurity = false; + + foreach ($this->commits($from, $to) as $commit) { + $trailers = self::parse($commit['message']); + + if ($trailers === null) { + $internal++; + + continue; + } + + [$category, $note] = $this->categoryFor($trailers['category'], $commit['subject']); + + if ($category === ChangelogCategory::Security) { + $hasSecurity = true; + + continue; + } + + $text = $trailers['public']; + + if ($text === null) { + $text = self::suggestionFrom($commit['subject']); + $note ??= 'Kein Public-Satz im Commit — Vorschlag aus dem Commit-Titel.'; + } + + $result = $this->store($commit['hash'], $releasedOn, $to, $category, $text, $commit['subject'], $note, $dryRun); + $result['created'] ? $created++ : $existing++; + $entries[] = $result['entry']; + } + + if ($hasSecurity) { + $result = $this->store('security:'.$to, $releasedOn, $to, ChangelogCategory::Security, ChangelogCategory::SECURITY_LINE, null, null, $dryRun); + $result['created'] ? $created++ : $existing++; + $entries[] = $result['entry']; + } + + return ['created' => $created, 'existing' => $existing, 'internal' => $internal, 'entries' => $entries]; + } + + /** + * Zeilen `Changelog:` und `Public:` aus einer Commit-Nachricht. Null, wenn + * es keine gültige `Changelog:`-Zeile gibt. + * + * @return array{category: ChangelogCategory, public: string|null}|null + */ + public static function parse(string $message): ?array + { + if (! preg_match('/^Changelog:[ \t]*(.+)$/mi', $message, $categoryMatch)) { + return null; + } + + $category = ChangelogCategory::fromTrailer($categoryMatch[1]); + + if ($category === null) { + return null; + } + + $public = null; + + // Public darf umbrechen: bis zur nächsten Leerzeile oder Trailer-Zeile. + if (preg_match('/^Public:[ \t]*(.+(?:\n(?![ \t]*\n)(?![A-Za-z-]+:[ \t]).+)*)/mi', $message, $publicMatch)) { + $public = trim((string) preg_replace('/\s+/', ' ', $publicMatch[1])); + $public = $public === '' ? null : $public; + } + + return ['category' => $category, 'public' => $public]; + } + + /** + * Vorschlag aus dem Commit-Titel: ohne Bereichspräfix (`web: …`), erster + * Buchstabe groß, mit Punkt. + */ + public static function suggestionFrom(string $subject): string + { + $text = trim((string) preg_replace('/^[a-z][\w-]*(\([^)]*\))?!?:\s*/i', '', $subject)); + + return rtrim(mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1), '.').'.'; + } + + /** + * @return array{0: ChangelogCategory, 1: string|null} + */ + private function categoryFor(ChangelogCategory $category, string $subject): array + { + if ($category === ChangelogCategory::Security) { + return [$category, null]; + } + + $lower = mb_strtolower($subject); + + foreach (self::SECURITY_TERMS as $term) { + if (preg_match('/\b'.preg_quote($term, '/').'\b/u', $lower)) { + return [ChangelogCategory::Security, sprintf('Automatisch auf Sicherheit gesetzt („%s“ im Titel, Rubrik war %s) — zurückstufen nur bewusst.', $term, $category->label())]; + } + } + + return [$category, null]; + } + + /** + * @return array{created: bool, entry: ChangelogEntry} + */ + private function store(string $key, Carbon $releasedOn, string $tag, ChangelogCategory $category, string $text, ?string $subject, ?string $note, bool $dryRun): array + { + $existing = ChangelogEntry::query()->where('source_key', $key)->first(); + + if ($existing !== null) { + return ['created' => false, 'entry' => $existing]; + } + + $entry = new ChangelogEntry([ + 'released_on' => $releasedOn->toDateString(), + 'release_tag' => mb_substr($tag, 0, 20), + 'category' => $category, + 'text' => mb_substr($text, 0, 500), + 'status' => ChangelogStatus::Draft, + 'source_key' => $key, + 'commit_subject' => $subject === null ? null : mb_substr($subject, 0, 255), + 'review_note' => $note === null ? null : mb_substr($note, 0, 255), + ]); + + if (! $dryRun) { + $entry->save(); + } + + return ['created' => true, 'entry' => $entry]; + } + + /** + * Commits von `$from` (ausschließlich) bis `$to`, älteste zuerst, ohne + * Merges. + * + * @return list + */ + private function commits(string $from, string $to): array + { + $result = Process::path($this->repositoryPath ?? base_path())->run([ + 'git', 'log', '--no-merges', '--reverse', '--format=%H%x1f%s%x1f%B%x1e', $from.'..'.$to, + ]); + + if (! $result->successful()) { + throw new RuntimeException(sprintf('git log %s..%s fehlgeschlagen: %s', $from, $to, trim($result->errorOutput()))); + } + + $commits = []; + + foreach (explode("\x1e", $result->output()) as $record) { + $parts = explode("\x1f", trim($record), 3); + + if (count($parts) === 3 && $parts[0] !== '') { + $commits[] = ['hash' => $parts[0], 'subject' => $parts[1], 'message' => $parts[2]]; + } + } + + return $commits; + } +} diff --git a/app/Services/Changelog/ChangelogTimeline.php b/app/Services/Changelog/ChangelogTimeline.php new file mode 100644 index 00000000..4f126858 --- /dev/null +++ b/app/Services/Changelog/ChangelogTimeline.php @@ -0,0 +1,69 @@ + + */ + public function entries(int $limit = 200): Collection + { + $entries = ChangelogEntry::query() + ->published() + ->orderByDesc('released_on') + ->orderBy('id') + ->limit($limit) + ->get(); + + $seenSecurityDays = []; + + return $entries + ->filter(function (ChangelogEntry $entry) use (&$seenSecurityDays): bool { + if ($entry->category !== ChangelogCategory::Security) { + return true; + } + + $day = $entry->released_on->toDateString(); + + if (isset($seenSecurityDays[$day])) { + return false; + } + + $seenSecurityDays[$day] = true; + + return true; + }) + ->sortBy([ + fn (ChangelogEntry $a, ChangelogEntry $b): int => $b->released_on <=> $a->released_on, + fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->category->order() <=> $b->category->order(), + fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->id <=> $b->id, + ]) + ->values(); + } + + /** + * Gibt es einen freigegebenen Eintrag der letzten Tage? Für den + * „Neu“-Hinweis im Kundenbereich (E11). + */ + public function hasRecent(int $days = 14): bool + { + return ChangelogEntry::query() + ->published() + ->where('released_on', '>=', Carbon::today()->subDays($days)->toDateString()) + ->exists(); + } +} diff --git a/database/factories/ChangelogEntryFactory.php b/database/factories/ChangelogEntryFactory.php new file mode 100644 index 00000000..fa04619c --- /dev/null +++ b/database/factories/ChangelogEntryFactory.php @@ -0,0 +1,39 @@ + + */ +class ChangelogEntryFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'released_on' => now()->toDateString(), + 'release_tag' => 'v1.'.fake()->numberBetween(1, 30).'.0', + 'category' => ChangelogCategory::Improved, + 'text' => fake()->sentence(10), + 'status' => ChangelogStatus::Draft, + 'source_key' => fake()->unique()->sha1(), + ]; + } + + public function published(): static + { + return $this->state(['status' => ChangelogStatus::Published, 'published_at' => now()]); + } + + public function category(ChangelogCategory $category): static + { + return $this->state(['category' => $category]); + } +} diff --git a/database/migrations/2026_10_14_102302_create_changelog_entries_table.php b/database/migrations/2026_10_14_102302_create_changelog_entries_table.php new file mode 100644 index 00000000..7fc0a419 --- /dev/null +++ b/database/migrations/2026_10_14_102302_create_changelog_entries_table.php @@ -0,0 +1,42 @@ +id(); + $table->date('released_on'); + $table->string('release_tag', 20)->nullable(); + $table->string('category', 20); + $table->string('text', 500); + $table->string('status', 20)->default('draft')->index(); + $table->string('source_key', 64)->unique(); + $table->string('commit_subject', 255)->nullable(); + $table->string('review_note', 255)->nullable(); + $table->boolean('article_worthy')->default(false); + $table->timestamp('published_at')->nullable(); + $table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['status', 'released_on']); + }); + } + + public function down(): void + { + Schema::dropIfExists('changelog_entries'); + } +}; diff --git a/database/seeders/data/changelog-backfill.php b/database/seeders/data/changelog-backfill.php new file mode 100644 index 00000000..51b7be23 --- /dev/null +++ b/database/seeders/data/changelog-backfill.php @@ -0,0 +1,35 @@ + + */ +return [ + ['date' => '2026-07-20', 'tag' => 'v1.0.0', 'category' => 'neu', 'text' => 'presseecho und businessportal24 laufen auf dem neuen System: Mit einem Konto bei pressekonto veröffentlichen Sie auf beiden Portalen, jede Meldung wird vor der Veröffentlichung automatisiert geprüft.'], + ['date' => '2026-09-15', 'tag' => null, 'category' => 'neu', 'text' => 'Jede Firma hat einen Newsroom mit all ihren Meldungen. Über „Diesen Newsroom verwalten“ pflegen Sie Beschreibung und Pressekontakte kostenlos.'], + ['date' => '2026-09-16', 'tag' => null, 'category' => 'neu', 'text' => 'Im Kundenbereich zeigt jede veröffentlichte Meldung ihre Aufrufe Tag für Tag.'], + ['date' => '2026-09-16', 'tag' => null, 'category' => 'neu', 'text' => 'Die Seite „Was eine gute Pressemitteilung ausmacht“ erklärt Anlass, Aufbau, Sprache und den Ablauf der Prüfung.'], + ['date' => '2026-09-17', 'tag' => null, 'category' => 'neu', 'text' => 'Jede Branche hat ein Jahresarchiv mit allen Meldungen eines Jahres.'], + ['date' => '2026-09-17', 'tag' => null, 'category' => 'neu', 'text' => 'Neue Infoseiten erklären, was eine Pressemitteilung kostet, wie Sie ohne Abo veröffentlichen und wie sich eine Meldung ändern oder löschen lässt.'], + ['date' => '2026-09-21', 'tag' => null, 'category' => 'neu', 'text' => 'RSS-Feeds für jedes Portal, jede Branche und jeden Newsroom; die Seite „RSS“ erklärt, wie Sie sie abonnieren.'], + ['date' => '2026-09-28', 'tag' => null, 'category' => 'neu', 'text' => 'Beim Hochladen eines Pressebildes bestätigen Sie die Bildrechte und geben an, ob das Bild mit KI erzeugt wurde. Unter jedem Bild steht der Bildnachweis.'], + ['date' => '2026-10-06', 'tag' => 'v1.3.0', 'category' => 'neu', 'text' => 'Rechnungen kommen per E-Mail.'], + ['date' => '2026-10-07', 'tag' => 'v1.4.0', 'category' => 'neu', 'text' => 'Anmelden geht jetzt auch mit einem Passkey – per Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel.'], + ['date' => '2026-10-07', 'tag' => 'v1.4.0', 'category' => 'sicherheit', 'text' => 'Sicherheitsverbesserungen eingespielt.'], + ['date' => '2026-10-08', 'tag' => 'v1.6.0', 'category' => 'neu', 'text' => 'In den Sicherheitseinstellungen melden Sie mit einem Klick alle anderen Geräte ab.'], + ['date' => '2026-10-08', 'tag' => 'v1.6.0', 'category' => 'sicherheit', 'text' => 'Sicherheitsverbesserungen eingespielt.'], + ['date' => '2026-10-09', 'tag' => 'v1.7.0', 'category' => 'verbessert', 'text' => 'Kontaktformular und Registrierung sind besser vor Spam geschützt, ohne Bilderrätsel.'], + ['date' => '2026-10-12', 'tag' => 'v1.8.0', 'category' => 'neu', 'text' => 'Die Sicherheitsseite im Konto zeigt dieses Gerät und Ihre letzten Anmeldungen.'], + ['date' => '2026-10-13', 'tag' => 'v1.9.0', 'category' => 'verbessert', 'text' => 'Gebuchte Platzierungen auf Start- und Branchenseiten heißen jetzt „Gebuchte Platzierung“; ein Tipp auf das Schild erklärt, was das bedeutet.'], +]; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 9c662e8e..d1a9775d 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -41,6 +41,8 @@ : 0; $legalOpenCount = $canAdmin ? \App\Models\LegalRequest::query()->open()->count() : 0; $contactOpenCount = $canAdmin ? \App\Models\ContactMessage::query()->open()->count() : 0; + // „Neu“-Hinweis (Changelog E11): freigegebener Eintrag der letzten 14 Tage. + $changelogIsNew = \Illuminate\Support\Facades\Cache::remember(\App\Services\Changelog\ChangelogTimeline::RECENT_CACHE_KEY, 600, fn (): bool => app(\App\Services\Changelog\ChangelogTimeline::class)->hasRecent()); @endphp @@ -67,6 +69,10 @@ :current="request()->routeIs('me.press-kits.*')" wire:navigate> {{ __('Firmen') }} + + {{ __('Was ist neu') }} + @@ -219,6 +225,13 @@ {{ __('Voreinstellungen') }} + @can('settings:manage') + + {{ __('Changelog') }} + + @endcan + @can('roles:manage') diff --git a/resources/views/livewire/admin/changelog/index.blade.php b/resources/views/livewire/admin/changelog/index.blade.php new file mode 100644 index 00000000..05475c4e --- /dev/null +++ b/resources/views/livewire/admin/changelog/index.blade.php @@ -0,0 +1,280 @@ +authorize('settings:manage'); + $this->newDate = now()->toDateString(); + } + + public function edit(int $id): void + { + $entry = ChangelogEntry::query()->findOrFail($id); + $this->editingId = $entry->id; + $this->editText = $entry->text; + $this->editCategory = $entry->category->value; + $this->editDate = $entry->released_on->toDateString(); + } + + public function cancelEdit(): void + { + $this->reset('editingId', 'editText', 'editCategory', 'editDate'); + } + + public function saveEdit(): void + { + $this->authorize('settings:manage'); + + $data = $this->validate([ + 'editText' => ['required', 'string', 'min:10', 'max:500'], + 'editCategory' => ['required', 'in:'.implode(',', array_column(ChangelogCategory::cases(), 'value'))], + 'editDate' => ['required', 'date'], + ]); + + ChangelogEntry::query()->findOrFail($this->editingId)->update([ + 'text' => trim($data['editText']), + 'category' => $data['editCategory'], + 'released_on' => $data['editDate'], + 'reviewed_by_user_id' => auth()->id(), + ]); + + $this->cancelEdit(); + } + + public function publish(int $id): void + { + $this->setStatus([$id], ChangelogStatus::Published); + } + + public function discard(int $id): void + { + $this->setStatus([$id], ChangelogStatus::Discarded); + } + + public function restore(int $id): void + { + $this->setStatus([$id], ChangelogStatus::Draft); + } + + /** + * Alle Entwürfe eines Tages auf einmal freigeben. + */ + public function publishDay(string $day): void + { + $ids = ChangelogEntry::query() + ->where('status', ChangelogStatus::Draft->value) + ->whereDate('released_on', $day) + ->pluck('id') + ->all(); + + $this->setStatus($ids, ChangelogStatus::Published); + } + + public function toggleArticle(int $id): void + { + $this->authorize('settings:manage'); + + $entry = ChangelogEntry::query()->findOrFail($id); + $entry->update(['article_worthy' => ! $entry->article_worthy]); + } + + public function add(): void + { + $this->authorize('settings:manage'); + + $data = $this->validate([ + 'newDate' => ['required', 'date'], + 'newCategory' => ['required', 'in:'.implode(',', array_column(ChangelogCategory::cases(), 'value'))], + 'newText' => ['required', 'string', 'min:10', 'max:500'], + ]); + + ChangelogEntry::query()->create([ + 'released_on' => $data['newDate'], + 'category' => $data['newCategory'], + 'text' => trim($data['newText']), + 'status' => ChangelogStatus::Draft, + 'source_key' => 'manual:'.Str::uuid()->toString(), + 'reviewed_by_user_id' => auth()->id(), + ]); + + $this->reset('newText'); + $this->status = 'draft'; + Flux::toast(text: __('Entwurf angelegt.'), variant: 'success'); + } + + /** + * @param list $ids + */ + private function setStatus(array $ids, ChangelogStatus $status): void + { + $this->authorize('settings:manage'); + + if ($ids === []) { + return; + } + + ChangelogEntry::query()->whereIn('id', $ids)->update([ + 'status' => $status->value, + 'published_at' => $status === ChangelogStatus::Published ? now() : null, + 'reviewed_by_user_id' => auth()->id(), + 'updated_at' => now(), + ]); + + Cache::forget(ChangelogTimeline::RECENT_CACHE_KEY); + } + + public function with(): array + { + $query = ChangelogEntry::query()->orderByDesc('released_on')->orderBy('id'); + + if (in_array($this->status, array_column(ChangelogStatus::cases(), 'value'), true)) { + $query->where('status', $this->status); + } + + /** @var Collection> $days */ + $days = $query->limit(300)->get()->groupBy(fn (ChangelogEntry $entry): string => $entry->released_on->toDateString()); + + return [ + 'days' => $days, + 'counts' => ChangelogEntry::query()->selectRaw('status, count(*) as total')->groupBy('status')->pluck('total', 'status')->all(), + 'categories' => ChangelogCategory::cases(), + 'statuses' => ChangelogStatus::cases(), + ]; + } +}; ?> + +
+ + +
+ @foreach ($statuses as $statusCase) + + {{ $statusCase->label() }} · {{ $counts[$statusCase->value] ?? 0 }} + + @endforeach + {{ __('Alle') }} + + {{ __('Öffentliche Seite') }} +
+ + @forelse ($days as $day => $entries) +
+
+ {{ \Illuminate\Support\Carbon::parse($day)->format('d.m.Y') }}@if ($entries->first()->release_tag) · {{ $entries->first()->release_tag }}@endif + @if ($entries->contains(fn ($entry) => $entry->status === ChangelogStatus::Draft)) + {{ __('Alle Entwürfe freigeben') }} + @endif +
+
+ @foreach ($entries as $entry) +
+ @if ($editingId === $entry->id) +
+ + + @foreach ($categories as $category) + {{ $category->label() }} + @endforeach + + +
+
+ {{ __('Speichern') }} + {{ __('Abbrechen') }} +
+ @else +
+ {{ $entry->category->label() }} +
+

{{ $entry->publicText() }}

+ @if ($entry->review_note) +

{{ $entry->review_note }}

+ @endif + @if ($entry->commit_subject) +

{{ $entry->commit_subject }}

+ @endif +
+ {{ $entry->status->label() }} +
+
+ @if ($entry->status === ChangelogStatus::Draft) + {{ __('Freigeben') }} + {{ __('Verwerfen') }} + @else + {{ __('Zurück zum Entwurf') }} + @endif + @if ($entry->category !== ChangelogCategory::Security) + {{ __('Bearbeiten') }} + + {{ $entry->article_worthy ? __('Artikelwürdig ✓') : __('Artikelwürdig') }} + + @endif +
+ @endif +
+ @endforeach +
+
+ @empty +
{{ __('Keine Einträge in dieser Ansicht.') }}
+ @endforelse + +
+
+ {{ __('Eintrag von Hand') }} + {{ __('für Neuerungen ohne Commit-Zeile; landet als Entwurf') }} +
+
+ + + @foreach ($categories as $category) + {{ $category->label() }} + @endforeach + + + {{ __('Anlegen') }} + +
+
diff --git a/resources/views/web/changelog.blade.php b/resources/views/web/changelog.blade.php new file mode 100644 index 00000000..5fd25728 --- /dev/null +++ b/resources/views/web/changelog.blade.php @@ -0,0 +1,66 @@ +@extends('web.layouts.web-master') + +@section('title', 'Changelog – Was ist neu bei pressekonto') +@section('meta_description', 'Alle Neuerungen auf pressekonto, presseecho und businessportal24, datiert und nach Rubriken: neu, verbessert, behoben.') + +@section('feed_links') + +@endsection + +{{-- + Öffentlicher Changelog (Decision-Update Changelog, E2/E3/E5/E19): nur + freigegebene Einträge, datiert, nach Rubriken. Neuigkeiten, kein + Reparaturprotokoll; Sicherheit nur als Sammelzeile. +--}} +@section('content') + @php + \Carbon\Carbon::setLocale('de'); + $badge = [ + 'neu' => 'border-brand/40 bg-brand/[0.06] text-brand', + 'verbessert' => 'border-bg-rule-strong bg-bg-elev text-ink-2', + 'behoben' => 'border-bg-rule-strong bg-bg text-ink-3', + 'sicherheit' => 'border-bg-rule-strong bg-bg text-ink-3', + ]; + $months = $entries->groupBy(fn ($entry) => $entry->released_on->format('Y-m')); + @endphp + + +
+

+ + + Changelog als RSS-Feed abonnieren + +

+ + @forelse ($months as $month => $monthEntries) +
+

+ {{ $monthEntries->first()->released_on->translatedFormat('F Y') }} +

+ + @foreach ($monthEntries->groupBy(fn ($entry) => $entry->released_on->toDateString()) as $day => $dayEntries) +
+ +
    + @foreach ($dayEntries as $entry) +
  • + {{ $entry->category->label() }} + {{ $entry->publicText() }} +
  • + @endforeach +
+
+ @endforeach +
+ @empty +

Noch keine Einträge.

+ @endforelse +
+
+@endsection diff --git a/routes/admin.php b/routes/admin.php index a4c1ab68..f40a9475 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -167,5 +167,9 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens Route::middleware('can:settings:manage')->group(function (): void { Route::livewire('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health'); + + // Changelog (Decision-Update Changelog, Stufe 3): Entwürfe aus dem + // Deploy gegenlesen und freigeben. + Route::livewire('admin/changelog', 'admin.changelog.index')->name('admin.changelog.index'); }); }); diff --git a/routes/web.php b/routes/web.php index 4f16d0eb..e693207d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Enums\Portal; use App\Http\Controllers\PressReleasePreviewController; use App\Http\Controllers\Web\CategoryArchiveController; use App\Http\Controllers\Web\CategoryController; +use App\Http\Controllers\Web\ChangelogController; use App\Http\Controllers\Web\ContactController; use App\Http\Controllers\Web\FeedController; use App\Http\Controllers\Web\HomeController; @@ -73,6 +74,11 @@ Route::get('/', function () use ($applyWebDomainConfig) { return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301); })->name('home'); +// Changelog (Decision-Update Changelog, E2): gehört zum Hub, auf den Portalen +// leitet der Controller dorthin weiter. +Route::get('/changelog', [ChangelogController::class, 'index'])->name('changelog'); +Route::get('/changelog/feed', [ChangelogController::class, 'feed'])->name('changelog.feed'); + // --------------------------------------------------------------------------- // Legacy .html-URLs -> saubere 301-Weiterleitung (vor der Edition-Gruppe, damit // `category/x.html` nicht als Slug `x.html` in die Kategorie-Route läuft). diff --git a/tests/Feature/ChangelogTest.php b/tests/Feature/ChangelogTest.php new file mode 100644 index 00000000..9a1fd4c0 --- /dev/null +++ b/tests/Feature/ChangelogTest.php @@ -0,0 +1,196 @@ + $messages + */ +function changelogRepository(array $messages): string +{ + $path = sys_get_temp_dir().'/changelog-test-'.uniqid(); + File::ensureDirectoryExists($path); + $git = fn (array $command) => Process::path($path)->run(['git', '-c', 'user.name=Test', '-c', 'user.email=test@example.org', ...$command])->throw(); + + $git(['init', '--quiet']); + $git(['commit', '--allow-empty', '--quiet', '-m', 'start']); + $git(['tag', 'v0.1.0']); + + foreach ($messages as $message) { + $git(['commit', '--allow-empty', '--quiet', '-m', $message]); + } + + $git(['tag', 'v0.2.0']); + + return $path; +} + +// ---------------------------------------------------------- Stufe 1 und 2 + +test('the commit lines are read from anywhere in the message, not only from the trailer block', function () { + /** @var TestCase $this */ + $parsed = ChangelogCollector::parse("web: Kasten\n\nText.\n\nChangelog: Verbessert\nPublic: Auf jeder Newsroom-Seite\nführt ein Kasten zum Zugang.\n\nCo-Authored-By: X "); + + expect($parsed['category'])->toBe(ChangelogCategory::Improved) + ->and($parsed['public'])->toBe('Auf jeder Newsroom-Seite führt ein Kasten zum Zugang.') + ->and(ChangelogCollector::parse("dev: intern\n\nNur Doku."))->toBeNull() + ->and(ChangelogCollector::parse("x: y\n\nChangelog: Irgendwas"))->toBeNull() + ->and(ChangelogCollector::suggestionFrom('web(newsroom): Newsroom-Seite mit Kasten'))->toBe('Newsroom-Seite mit Kasten.'); +}); + +test('a deploy collects drafts: internal commits stay out, security becomes one line, missing public sentences are marked', function () { + /** @var TestCase $this */ + $repository = changelogRepository([ + "web: Newsroom-Kasten\n\nChangelog: Neu\nPublic: Jede Newsroom-Seite hat einen Kasten zum Verwalten.", + 'dev: Roadmap gepflegt', + "web: Firmenseite verlinkt den Newsroom\n\nChangelog: Verbessert", + "fix(auth): Session nach Passwortwechsel beenden\n\nChangelog: Behoben\nPublic: Nach dem Passwortwechsel ist man abgemeldet.", + "security: Header gesetzt\n\nChangelog: Sicherheit", + ]); + + $collector = new ChangelogCollector($repository); + $result = $collector->collect('v0.1.0', 'v0.2.0'); + + expect($result['created'])->toBe(3) + ->and($result['internal'])->toBe(1); + + $entries = ChangelogEntry::query()->orderBy('id')->get(); + expect($entries->pluck('status')->unique()->all())->toBe([ChangelogStatus::Draft]) + ->and($entries[0]->text)->toBe('Jede Newsroom-Seite hat einen Kasten zum Verwalten.') + ->and($entries[1]->text)->toBe('Firmenseite verlinkt den Newsroom.') + ->and($entries[1]->review_note)->toContain('Kein Public-Satz') + // „Session“ im Titel, Rubrik Behoben → Sicherheit, ohne Beschreibung. + ->and($entries->where('category', ChangelogCategory::Security))->toHaveCount(1) + ->and($entries[2]->text)->toBe(ChangelogCategory::SECURITY_LINE) + ->and($entries[2]->release_tag)->toBe('v0.2.0'); + + // Ein zweiter Lauf (Deploy wiederholt) legt nichts doppelt an. + expect($collector->collect('v0.1.0', 'v0.2.0')['created'])->toBe(0) + ->and(ChangelogEntry::query()->count())->toBe(3); + + File::deleteDirectory($repository); +}); + +test('an unknown tag fails loudly instead of collecting nothing', function () { + /** @var TestCase $this */ + $repository = changelogRepository([]); + + expect(fn () => (new ChangelogCollector($repository))->collect('v9.9.9', 'v0.2.0'))->toThrow(RuntimeException::class); + + File::deleteDirectory($repository); +}); + +test('the backfill adds the entries since the relaunch as drafts, once', function () { + /** @var TestCase $this */ + $this->artisan('changelog:backfill')->assertSuccessful(); + $count = ChangelogEntry::query()->count(); + + $this->artisan('changelog:backfill')->assertSuccessful(); + + expect($count)->toBeGreaterThan(10) + ->and(ChangelogEntry::query()->count())->toBe($count) + ->and(ChangelogEntry::query()->where('status', '!=', ChangelogStatus::Draft->value)->exists())->toBeFalse(); +}); + +// -------------------------------------------------------- Öffentliche Seite + +test('the public changelog shows only published entries and security once per day', function () { + /** @var TestCase $this */ + ChangelogEntry::factory()->published()->create(['released_on' => '2026-10-13', 'text' => 'Gebuchte Platzierungen sind gekennzeichnet.']); + ChangelogEntry::factory()->create(['released_on' => '2026-10-13', 'text' => 'Noch nicht freigegeben.']); + ChangelogEntry::factory()->published()->category(ChangelogCategory::Security)->count(2)->create(['released_on' => '2026-10-08', 'text' => 'Details zur Lücke']); + + $response = $this->get('https://pressekonto.test/changelog') + ->assertOk() + ->assertSeeText('Was ist neu') + ->assertSeeText('Gebuchte Platzierungen sind gekennzeichnet.') + ->assertDontSeeText('Noch nicht freigegeben.') + ->assertDontSeeText('Details zur Lücke') + ->assertSee('/changelog/feed', false); + + expect(substr_count($response->getContent(), ChangelogCategory::SECURITY_LINE))->toBe(1); +}); + +test('the changelog belongs to the hub and the feed is rss', function () { + /** @var TestCase $this */ + ChangelogEntry::factory()->published()->create(['text' => 'Rechnungen kommen per E-Mail.']); + + $this->get('https://presseecho.test/changelog')->assertRedirect('https://pressekonto.test/changelog'); + + $this->get('https://pressekonto.test/changelog/feed') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8') + ->assertSee('assertSee('Rechnungen kommen per E-Mail.', false); +}); + +test('the new hint shows only with a recent published entry', function () { + /** @var TestCase $this */ + $timeline = app(ChangelogTimeline::class); + ChangelogEntry::factory()->published()->create(['released_on' => now()->subDays(30)->toDateString()]); + expect($timeline->hasRecent())->toBeFalse(); + + ChangelogEntry::factory()->published()->create(['released_on' => now()->subDays(2)->toDateString()]); + expect($timeline->hasRecent())->toBeTrue(); +}); + +// ------------------------------------------------------------- Admin + +test('admins review, edit and publish drafts', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $draft = ChangelogEntry::factory()->create(['released_on' => '2026-10-14', 'text' => 'Erster Entwurf des Satzes.']); + $other = ChangelogEntry::factory()->create(['released_on' => '2026-10-14']); + + $this->actingAs($admin)->get(route('admin.changelog.index'))->assertOk()->assertSeeText('Erster Entwurf des Satzes.'); + + Livewire::test('admin.changelog.index') + ->call('edit', $draft->id) + ->set('editText', 'Kurz') + ->call('saveEdit') + ->assertHasErrors('editText') + ->set('editText', 'Die Firmenseite verlinkt den Newsroom.') + ->set('editCategory', ChangelogCategory::New->value) + ->call('saveEdit') + ->assertHasNoErrors() + ->call('publish', $draft->id) + ->call('discard', $other->id); + + $draft->refresh(); + expect($draft->text)->toBe('Die Firmenseite verlinkt den Newsroom.') + ->and($draft->category)->toBe(ChangelogCategory::New) + ->and($draft->status)->toBe(ChangelogStatus::Published) + ->and($draft->published_at)->not->toBeNull() + ->and($draft->reviewed_by_user_id)->toBe($admin->id) + ->and($other->fresh()->status)->toBe(ChangelogStatus::Discarded); + + Livewire::test('admin.changelog.index')->call('restore', $other->id)->call('publishDay', '2026-10-14'); + expect($other->fresh()->status)->toBe(ChangelogStatus::Published); +}); + +test('editors without settings permission cannot open the changelog admin', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $editor = User::factory()->create(['is_active' => true]); + $editor->assignRole('editor'); + + $this->actingAs($editor)->get(route('admin.changelog.index'))->assertForbidden(); +});