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:
parent
ebd61c8fd9
commit
2c590fa9df
17 changed files with 1302 additions and 0 deletions
54
app/Console/Commands/BackfillChangelog.php
Normal file
54
app/Console/Commands/BackfillChangelog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/CollectChangelog.php
Normal file
61
app/Console/Commands/CollectChangelog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
app/Enums/ChangelogCategory.php
Normal file
48
app/Enums/ChangelogCategory.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
28
app/Enums/ChangelogStatus.php
Normal file
28
app/Enums/ChangelogStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/Web/ChangelogController.php
Normal file
81
app/Http/Controllers/Web/ChangelogController.php
Normal 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'), '/');
|
||||
}
|
||||
}
|
||||
71
app/Models/ChangelogEntry.php
Normal file
71
app/Models/ChangelogEntry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
209
app/Services/Changelog/ChangelogCollector.php
Normal file
209
app/Services/Changelog/ChangelogCollector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
app/Services/Changelog/ChangelogTimeline.php
Normal file
69
app/Services/Changelog/ChangelogTimeline.php
Normal 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();
|
||||
}
|
||||
}
|
||||
39
database/factories/ChangelogEntryFactory.php
Normal file
39
database/factories/ChangelogEntryFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
35
database/seeders/data/changelog-backfill.php
Normal file
35
database/seeders/data/changelog-backfill.php
Normal 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.'],
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
280
resources/views/livewire/admin/changelog/index.blade.php
Normal file
280
resources/views/livewire/admin/changelog/index.blade.php
Normal 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>
|
||||
66
resources/views/web/changelog.blade.php
Normal file
66
resources/views/web/changelog.blade.php
Normal 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
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
196
tests/Feature/ChangelogTest.php
Normal file
196
tests/Feature/ChangelogTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue