Add restore_point functionality to Meetups

- 💾 Introduced `restore_point` JSON column in `meetups` table for saving and restoring master data.
- 🛠️ Added methods `captureRestorePoint` and `restoreFromRestorePoint` to `Meetup` model for managing restore points.
- 🔒 Implemented authorization for updating meetups via `updateViaPortal` policy to include pivot members.
- 🔗 Created Artisan commands `meetups:snapshot` and `meetups:restore` for managing restore points from CLI.
- 🚦 Added rate limiter to restrict excessive update attempts in Livewire meetup editing.
-  Developed exhaustive feature tests for snapshot and restore actions, portal editing rules, and rate limiting.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-10 10:56:38 +02:00
parent 8c68b19138
commit f5cf85b438
9 changed files with 350 additions and 6 deletions
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands\Database;
use App\Models\Meetup;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('meetups:restore {meetup : ID des wiederherzustellenden Meetups}')]
#[Description('Restore the master data of a meetup from its saved restore point.')]
class RestoreMeetupFromRestorePoint extends Command
{
public function handle(): int
{
$meetupId = $this->argument('meetup');
$meetup = Meetup::query()->find($meetupId);
if ($meetup === null) {
$this->error("Meetup [{$meetupId}] not found.");
return Command::FAILURE;
}
if (! $meetup->restoreFromRestorePoint()) {
$this->error("Meetup [{$meetup->id}] {$meetup->name} has no restore point. Run meetups:snapshot first.");
return Command::FAILURE;
}
$capturedAt = $meetup->restore_point['captured_at'] ?? 'unknown';
$this->info("Meetup [{$meetup->id}] {$meetup->name} restored from restore point (captured at {$capturedAt}).");
return Command::SUCCESS;
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands\Database;
use App\Models\Meetup;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('meetups:snapshot {meetup? : ID eines einzelnen Meetups; ohne Angabe werden alle Meetups gesichert}')]
#[Description('Save the current master data of meetups as a restore point (restore_point JSON column).')]
class SnapshotMeetupRestorePoints extends Command
{
public function handle(): int
{
$meetupId = $this->argument('meetup');
$count = 0;
Meetup::query()
->when($meetupId, fn ($query) => $query->whereKey($meetupId))
->chunkById(200, function ($meetups) use (&$count) {
foreach ($meetups as $meetup) {
$meetup->captureRestorePoint();
$count++;
}
});
if ($meetupId && $count === 0) {
$this->error("Meetup [{$meetupId}] not found.");
return Command::FAILURE;
}
$this->info("Restore points saved for {$count} meetups.");
return Command::SUCCESS;
}
}
+72
View File
@@ -59,8 +59,64 @@ class Meetup extends Model implements HasMedia
'simplified_geojson' => 'array',
'is_active' => 'boolean',
'last_event_at' => 'datetime',
'restore_point' => 'array',
];
/**
* Stammdaten, die im Restore-Point gesichert und wiederhergestellt werden.
*
* @var list<string>
*/
public const RESTORE_POINT_ATTRIBUTES = [
'name',
'slug',
'city_id',
'intro',
'telegram_link',
'webpage',
'twitter_username',
'matrix_group',
'nostr',
'simplex',
'signal',
'community',
'github_data',
'visible_on_map',
];
/**
* Sichert den aktuellen Stand der Stammdaten als Restore-Point.
* restore_point ist bewusst nicht fillable und nur per Command erreichbar.
*/
public function captureRestorePoint(): void
{
$this->forceFill([
'restore_point' => [
'captured_at' => now()->toIso8601String(),
'attributes' => $this->only(self::RESTORE_POINT_ATTRIBUTES),
],
])->saveQuietly();
}
/**
* Stellt die Stammdaten aus dem Restore-Point wieder her.
* Liefert false, wenn noch kein Restore-Point gesichert wurde.
*/
public function restoreFromRestorePoint(): bool
{
$attributes = $this->restore_point['attributes'] ?? null;
if (! is_array($attributes)) {
return false;
}
$this->forceFill(
collect($attributes)->only(self::RESTORE_POINT_ATTRIBUTES)->all()
)->save();
return true;
}
protected static function booted()
{
static::creating(function ($model) {
@@ -121,6 +177,22 @@ class Meetup extends Model implements HasMedia
return $this->belongsToMany(User::class);
}
/**
* Ist der Nutzer über die meetup_user-Pivot Mitglied dieses Meetups?
*/
public function hasMember(User $user): bool
{
return $this->users()->whereKey($user->id)->exists();
}
/**
* RateLimiter-Key für Meetup-Stammdaten-Updates über das Portal-Frontend.
*/
public static function updateRateLimitKey(int $userId): string
{
return 'meetup-update:'.$userId;
}
/**
* Meetups, die dem Nutzer zugeordnet sind: selbst erstellt (created_by) ODER
* Mitglied über die meetup_user-Pivot. Entspricht „Meine Meetups" im Portal.
+12
View File
@@ -29,4 +29,16 @@ class MeetupPolicy
{
return $this->owns($user, $meetup);
}
/**
* Gelockerte Update-Regel ausschließlich für das Portal-Frontend (Livewire):
* Neben dem Ersteller darf auch jedes Mitglied der meetup_user-Pivot
* („Meine Meetups" im Dashboard) die Stammdaten bearbeiten. REST-API und
* MCP nutzen weiterhin die strikte update()-Ability. Übergangslösung, bis
* ein echtes Rollen-/Freigabekonzept existiert.
*/
public function updateViaPortal(User $user, Meetup $meetup): bool
{
return $this->owns($user, $meetup) || $meetup->hasMember($user);
}
}