changelog: Entwürfe aus den Commits, Freigabe im Admin, öffentliche Seite mit RSS

Decision-Update Changelog, Stufen 1 bis 3:
- changelog:collect liest die Commits zwischen zwei Tags und legt aus den
  Zeilen Changelog:/Public: Entwürfe an (ganze Nachricht, nicht nur der
  Trailer-Block); ohne Changelog-Zeile intern, Sicherheit als eine
  Sammelzeile je Release, Sicherheitsbegriffe im Titel stufen hoch, ohne
  Public-Satz ein markierter Vorschlag aus dem Titel; idempotent.
- changelog:backfill trägt die Neuerungen seit dem Relaunch als Entwurf nach.
- Admin ▸ Changelog (settings:manage): gegenlesen, bearbeiten, Rubrik,
  artikelwürdig, freigeben/verwerfen, Tag gesammelt freigeben, Eintrag von Hand.
- pressekonto.com/changelog und /changelog/feed (RSS), auf den Portalen
  301 zum Hub; Kundenmenü „Was ist neu“ mit Neu-Kennzeichen (14 Tage).

Changelog: Neu
Public: Unter pressekonto.com/changelog steht, was sich auf pressekonto, presseecho und businessportal24 ändert – auch als RSS-Feed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-14 08:30:46 +00:00
parent ebd61c8fd9
commit 2c590fa9df
17 changed files with 1302 additions and 0 deletions

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use Illuminate\Console\Command;
/**
* Nachtrag des Changelogs seit dem Relaunch (Decision-Update Changelog, O7)
* aus `database/seeders/data/changelog-backfill.php` — als Entwurf, idempotent.
*/
class BackfillChangelog extends Command
{
protected $signature = 'changelog:backfill {--dry-run : nur zählen, nichts speichern}';
protected $description = 'Changelog-Einträge seit dem Relaunch als Entwurf nachtragen.';
public function handle(): int
{
/** @var list<array{date: string, tag: string|null, category: string, text: string}> $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;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Services\Changelog\ChangelogCollector;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Stufe 2 des Changelogs: Entwürfe aus den Commits eines Releases. Läuft in
* `dev/betrieb/deploy.sh` nach `up`; ein Fehler hier bricht keinen Deploy ab.
*/
class CollectChangelog extends Command
{
protected $signature = 'changelog:collect
{from : vorheriger Tag (ausschließlich), z. B. v1.11.0}
{to : neuer Tag, z. B. v1.12.0}
{--date= : Datum der Einträge (Standard: heute)}
{--dry-run : nur anzeigen, nichts speichern}';
protected $description = 'Changelog-Entwürfe aus den Commit-Zeilen Changelog:/Public: eines Releases anlegen.';
public function handle(ChangelogCollector $collector): int
{
try {
$result = $collector->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;
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* Rubriken des Changelogs (Decision-Update Changelog, E3). Sicherheit
* erscheint nur als Sammelzeile, nie mit Beschreibung (E5).
*/
enum ChangelogCategory: string
{
case New = 'neu';
case Improved = 'verbessert';
case Fixed = 'behoben';
case Security = 'sicherheit';
public const string SECURITY_LINE = 'Sicherheitsverbesserungen eingespielt.';
public function label(): string
{
return match ($this) {
self::New => '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)));
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ChangelogStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Discarded = 'discarded';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Published => 'Freigegeben',
self::Discarded => 'Verworfen',
};
}
public function badgeColor(): string
{
return match ($this) {
self::Draft => 'amber',
self::Published => 'green',
self::Discarded => 'zinc',
};
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\ChangelogEntry;
use App\Services\Changelog\ChangelogTimeline;
use App\Support\DomainAssetContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* Öffentlicher Changelog unter pressekonto.com/changelog mit RSS-Feed
* (Decision-Update Changelog, E2). Er gehört zum Hub; auf den Portalen leitet
* die Adresse dorthin weiter.
*/
class ChangelogController extends Controller
{
public function __construct(private readonly ChangelogTimeline $timeline) {}
public function index(Request $request): View|RedirectResponse
{
if (! $this->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'), '/');
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use Database\Factories\ChangelogEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Ein Changelog-Eintrag (Decision-Update Changelog): Entwurf aus dem Deploy,
* öffentlich erst nach Freigabe.
*/
class ChangelogEntry extends Model
{
/** @use HasFactory<ChangelogEntryFactory> */
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<User, $this>
*/
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
/**
* @param Builder<ChangelogEntry> $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;
}
}

View file

@ -0,0 +1,209 @@
<?php
namespace App\Services\Changelog;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Process;
use RuntimeException;
/**
* Stufe 2 des Changelogs (Decision-Update Changelog): Beim Deploy werden die
* Commits zwischen dem vorigen und dem neuen Tag gelesen und aus den Zeilen
* `Changelog:` / `Public:` Entwürfe angelegt. Veröffentlicht wird nichts —
* das macht der Betreiber im Admin (Stufe 3).
*
* Die Zeilen werden aus der ganzen Commit-Nachricht gelesen, nicht über
* `git interpret-trailers`: Git zählt nur den LETZTEN Absatz als Trailer, und
* dort steht meist `Co-Authored-By`.
*
* Regeln aus dem Decision-Update:
* - Ohne `Changelog:` bleibt ein Commit intern.
* - Ohne `Public:` wird ein Vorschlag aus dem Titel gemacht und markiert.
* - Sicherheit ergibt je Release genau eine Sammelzeile (E5).
* - Klingt der Titel nach Sicherheit, die Rubrik aber nicht, wird der
* Eintrag auf Sicherheit gesetzt und markiert — zurückstufen nur bewusst.
*/
class ChangelogCollector
{
/** Begriffe im Commit-Titel, die auf eine Sicherheitskorrektur deuten. */
private const array SECURITY_TERMS = [
'auth', 'session', 'token', 'csrf', 'xss', 'injection', 'permission', 'escape', 'sanitize', 'rate limit', 'rate-limit',
'sicherheit', 'security',
];
/**
* @param string|null $repositoryPath Git-Repository (Standard: die Anwendung selbst)
*/
public function __construct(private readonly ?string $repositoryPath = null) {}
/**
* @return array{created: int, existing: int, internal: int, entries: list<ChangelogEntry>}
*/
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<array{hash: string, subject: string, message: string}>
*/
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;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Changelog;
use App\Enums\ChangelogCategory;
use App\Models\ChangelogEntry;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* Öffentliche Sicht auf den Changelog: nur freigegebene Einträge, jüngste
* zuerst, je Tag in der Reihenfolge der Rubriken. Sicherheit erscheint je Tag
* höchstens einmal als Sammelzeile (E5) — auch wenn mehrere Releases an einem
* Tag eine mitbringen.
*/
class ChangelogTimeline
{
public const string RECENT_CACHE_KEY = 'changelog.has-recent';
/**
* @return Collection<int, ChangelogEntry>
*/
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();
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ChangelogEntry>
*/
class ChangelogEntryFactory extends Factory
{
/**
* @return array<string, mixed>
*/
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]);
}
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Changelog (Decision-Update Changelog, Stufen 2 und 3): Einträge entstehen als
* Entwurf aus den Commit-Zeilen `Changelog:`/`Public:` beim Deploy, der
* Betreiber liest gegen und gibt frei. Nur freigegebene Einträge sind
* öffentlich. `commit_hash` hält den Entwurf idempotent — ein wiederholter
* Deploy legt nichts doppelt an; Nachträge (Backfill) tragen einen eigenen
* Schlüssel statt eines Hashes.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('changelog_entries', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,35 @@
<?php
/**
* Nachtrag des Changelogs seit dem Relaunch (Decision-Update Changelog, O7).
*
* Vor v1.11.0 trugen die Commits keine Zeilen `Changelog:`/`Public:`. Diese
* Einträge sind aus dem Protokoll in dev/betrieb/git-deploy.md, der Roadmap
* und den Commit-Daten rekonstruiert. Sie werden als Entwurf angelegt
* (`php artisan changelog:backfill`) und erst nach dem Gegenlesen im Admin
* freigegeben. Für die Funktionen aus dem September ist das Datum das der
* Fertigstellung; ausgeliefert wurde damals noch per SFTP in Sammeldeploys.
*
* Tonfall (E19): Neuigkeiten, kein Reparaturprotokoll; Sicherheit nur als
* Sammelzeile (E5).
*
* @return list<array{date: string, tag: string|null, category: string, text: string}>
*/
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.'],
];

View file

@ -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
<flux:navlist variant="outline">
@ -67,6 +69,10 @@
:current="request()->routeIs('me.press-kits.*')" wire:navigate>
{{ __('Firmen') }}
</flux:navlist.item>
<flux:navlist.item icon="sparkles" :href="route('changelog')"
:badge="$changelogIsNew ? __('Neu') : null" badge-color="lime">
{{ __('Was ist neu') }}
</flux:navlist.item>
</flux:navlist.group>
<flux:navlist.group :heading="__('Finanzen')" class="grid mb-4">
@ -219,6 +225,13 @@
{{ __('Voreinstellungen') }}
</flux:navlist.item>
@can('settings:manage')
<flux:navlist.item icon="sparkles" :href="route('admin.changelog.index')"
:current="request()->routeIs('admin.changelog.*')" wire:navigate>
{{ __('Changelog') }}
</flux:navlist.item>
@endcan
@can('roles:manage')
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>

View file

@ -0,0 +1,280 @@
<?php
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use App\Services\Changelog\ChangelogTimeline;
use Flux\Flux;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Admin ▸ Changelog (Decision-Update Changelog, Stufe 3): Entwürfe aus dem
* Deploy gegenlesen, umformulieren, Rubrik ändern, als artikelwürdig
* markieren, freigeben oder verwerfen. Öffentlich wird nur, was hier
* freigegeben ist. Zielaufwand: unter fünf Minuten je Release.
*/
new #[Layout('components.layouts.app'), Title('Changelog')] class extends Component
{
#[Url]
public string $status = 'draft';
public ?int $editingId = null;
public string $editText = '';
public string $editCategory = '';
public string $editDate = '';
public string $newDate = '';
public string $newCategory = 'verbessert';
public string $newText = '';
public function mount(): void
{
$this->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<int> $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<string, Collection<int, ChangelogEntry>> $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(),
];
}
}; ?>
<div class="space-y-8">
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Changelog')"
:title="__('Changelog')"
:description="__('Entwürfe entstehen beim Deploy aus den Commit-Zeilen „Changelog:“ und „Public:“. Gegenlesen, bei Bedarf umformulieren, freigeben. Öffentlich ist nur Freigegebenes, unter pressekonto.com/changelog und im RSS-Feed. Sicherheit erscheint immer nur als Sammelzeile.')"
/>
<div class="flex flex-wrap items-center gap-2">
@foreach ($statuses as $statusCase)
<flux:button size="sm" :variant="$status === $statusCase->value ? 'primary' : 'filled'" wire:click="$set('status', '{{ $statusCase->value }}')">
{{ $statusCase->label() }} · {{ $counts[$statusCase->value] ?? 0 }}
</flux:button>
@endforeach
<flux:button size="sm" :variant="$status === 'all' ? 'primary' : 'filled'" wire:click="$set('status', 'all')">{{ __('Alle') }}</flux:button>
<flux:spacer />
<flux:button size="sm" variant="ghost" icon="arrow-top-right-on-square" href="{{ route('changelog') }}" target="_blank">{{ __('Öffentliche Seite') }}</flux:button>
</div>
@forelse ($days as $day => $entries)
<article class="panel overflow-hidden" wire:key="day-{{ $day }}">
<div class="panel-head">
<span class="section-eyebrow">{{ \Illuminate\Support\Carbon::parse($day)->format('d.m.Y') }}@if ($entries->first()->release_tag) · {{ $entries->first()->release_tag }}@endif</span>
@if ($entries->contains(fn ($entry) => $entry->status === ChangelogStatus::Draft))
<flux:button size="sm" variant="primary" icon="check" wire:click="publishDay('{{ $day }}')" wire:confirm="{{ __('Alle Entwürfe dieses Tages freigeben?') }}">{{ __('Alle Entwürfe freigeben') }}</flux:button>
@endif
</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@foreach ($entries as $entry)
<div class="px-5 py-4" wire:key="entry-{{ $entry->id }}">
@if ($editingId === $entry->id)
<div class="grid gap-3 sm:grid-cols-[160px_160px_1fr]">
<flux:input type="date" wire:model="editDate" :label="__('Datum')" />
<flux:select wire:model="editCategory" :label="__('Rubrik')">
@foreach ($categories as $category)
<flux:select.option :value="$category->value">{{ $category->label() }}</flux:select.option>
@endforeach
</flux:select>
<flux:textarea wire:model="editText" rows="2" :label="__('Text')" />
</div>
<div class="mt-3 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="saveEdit">{{ __('Speichern') }}</flux:button>
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
</div>
@else
<div class="flex flex-wrap items-start gap-3">
<flux:badge size="sm" :color="$entry->category === ChangelogCategory::Security ? 'zinc' : 'blue'">{{ $entry->category->label() }}</flux:badge>
<div class="min-w-0 flex-1">
<p class="m-0 text-[13.5px] leading-[1.5] text-[color:var(--color-ink)]">{{ $entry->publicText() }}</p>
@if ($entry->review_note)
<p class="m-0 mt-1 text-[11.5px] text-amber-700 dark:text-amber-400">{{ $entry->review_note }}</p>
@endif
@if ($entry->commit_subject)
<p class="m-0 mt-1 font-mono text-[11px] text-[color:var(--color-ink-4)]">{{ $entry->commit_subject }}</p>
@endif
</div>
<flux:badge size="sm" :color="$entry->status->badgeColor()">{{ $entry->status->label() }}</flux:badge>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
@if ($entry->status === ChangelogStatus::Draft)
<flux:button size="xs" variant="primary" wire:click="publish({{ $entry->id }})">{{ __('Freigeben') }}</flux:button>
<flux:button size="xs" variant="ghost" wire:click="discard({{ $entry->id }})">{{ __('Verwerfen') }}</flux:button>
@else
<flux:button size="xs" variant="ghost" wire:click="restore({{ $entry->id }})">{{ __('Zurück zum Entwurf') }}</flux:button>
@endif
@if ($entry->category !== ChangelogCategory::Security)
<flux:button size="xs" variant="ghost" icon="pencil" wire:click="edit({{ $entry->id }})">{{ __('Bearbeiten') }}</flux:button>
<flux:button size="xs" :variant="$entry->article_worthy ? 'filled' : 'ghost'" icon="newspaper" wire:click="toggleArticle({{ $entry->id }})">
{{ $entry->article_worthy ? __('Artikelwürdig ✓') : __('Artikelwürdig') }}
</flux:button>
@endif
</div>
@endif
</div>
@endforeach
</div>
</article>
@empty
<div class="panel px-5 py-6 text-[13px] text-[color:var(--color-ink-3)]">{{ __('Keine Einträge in dieser Ansicht.') }}</div>
@endforelse
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eintrag von Hand') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('für Neuerungen ohne Commit-Zeile; landet als Entwurf') }}</span>
</div>
<form wire:submit="add" class="grid gap-3 px-5 py-4 sm:grid-cols-[160px_160px_1fr_auto] sm:items-end">
<flux:input type="date" wire:model="newDate" :label="__('Datum')" />
<flux:select wire:model="newCategory" :label="__('Rubrik')">
@foreach ($categories as $category)
<flux:select.option :value="$category->value">{{ $category->label() }}</flux:select.option>
@endforeach
</flux:select>
<flux:input wire:model="newText" :label="__('Text')" :placeholder="__('Ein Satz aus Nutzersicht, Präsens')" />
<flux:button type="submit" variant="primary">{{ __('Anlegen') }}</flux:button>
</form>
</article>
</div>

View file

@ -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')
<link rel="alternate" type="application/rss+xml" title="pressekonto – Changelog" href="{{ $feedUrl }}">
@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
<x-web.static-page
eyebrow="Changelog"
title="Was ist neu"
lead="Neuerungen auf pressekonto, presseecho und businessportal24 – datiert und in einem Satz. Größere Neuerungen erklären wir zusätzlich in der Rubrik „Portal-Updates“."
breadcrumb="Changelog"
>
<div class="max-w-[760px]">
<p class="mb-8 text-[13px] text-ink-3">
<a href="{{ $feedUrl }}" type="application/rss+xml" class="inline-flex items-center gap-1.5 font-medium underline underline-offset-2 hover:text-ink">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 12.5A1.5 1.5 0 1 1 3.5 14 1.5 1.5 0 0 1 2 12.5ZM2 8a6 6 0 0 1 6 6h2a8 8 0 0 0-8-8Zm0-4a10 10 0 0 1 10 10h2A12 12 0 0 0 2 2Z"/></svg>
Changelog als RSS-Feed abonnieren
</a>
</p>
@forelse ($months as $month => $monthEntries)
<section class="mb-10" aria-labelledby="monat-{{ $month }}">
<h2 id="monat-{{ $month }}" class="font-serif text-[24px] font-semibold tracking-[-0.3px] text-ink m-0 mb-4 pb-2 border-b border-bg-rule">
{{ $monthEntries->first()->released_on->translatedFormat('F Y') }}
</h2>
@foreach ($monthEntries->groupBy(fn ($entry) => $entry->released_on->toDateString()) as $day => $dayEntries)
<div class="grid gap-2 sm:grid-cols-[110px_1fr] py-3 border-b border-bg-rule last:border-b-0">
<time datetime="{{ $day }}" class="font-mono text-[12px] text-ink-3 pt-0.5">{{ $dayEntries->first()->released_on->translatedFormat('j. F') }}</time>
<ul class="m-0 p-0 list-none space-y-2.5">
@foreach ($dayEntries as $entry)
<li id="eintrag-{{ $entry->id }}" class="flex items-start gap-3">
<span class="mt-0.5 inline-flex shrink-0 items-center border px-1.5 py-px font-mono text-[9.5px] font-bold uppercase tracking-[0.1em] {{ $badge[$entry->category->value] ?? '' }}">{{ $entry->category->label() }}</span>
<span class="text-[14.5px] leading-[1.55] text-ink">{{ $entry->publicText() }}</span>
</li>
@endforeach
</ul>
</div>
@endforeach
</section>
@empty
<p class="text-[14px] text-ink-2">Noch keine Einträge.</p>
@endforelse
</div>
</x-web.static-page>
@endsection

View file

@ -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');
});
});

View file

@ -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).

View file

@ -0,0 +1,196 @@
<?php
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use App\Models\User;
use App\Services\Changelog\ChangelogCollector;
use App\Services\Changelog\ChangelogTimeline;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Changelog (Decision-Update Changelog, Stufen 1–3): Entwürfe aus den
* Commit-Zeilen beim Deploy, Freigabe im Admin, öffentliche Seite mit RSS.
*/
/**
* Kleines Git-Repository mit einem Tag am Anfang und einem am Ende.
*
* @param list<string> $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 <x@example.org>");
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('<rss version="2.0"', false)
->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();
});