Implement leadership-based permissions for Meetup management

- 🔒 Restrict event creation, editing, and deletion to Meetup leaders (`is_leader`) and creators for consistency across APIs, frontend, and MCP.
-  Add new APIs for leader delegation: assign/remove Meetup leaders via `meetup_user.is_leader`.
- 🛠️ Replace loose member checks with specific leadership checks in policies, controllers, and views.
- 🧪 Add exhaustive tests to ensure only eligible leaders execute critical actions (e.g., event creation/edit, Meetup updates).
- 🔄 Refactor pivot relationships and models (`leadByMe`, `isLeader`) for explicit leadership handling.
-  Introduce artisan command `meetups:promote-existing-leaders` to transition legacy data.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-16 22:04:34 +02:00
parent 39af153f52
commit 9f8fda294a
26 changed files with 691 additions and 70 deletions
+39
View File
@@ -185,6 +185,19 @@ class Meetup extends Model implements HasMedia
return $this->users()->whereKey($user->id)->exists();
}
/**
* Ist der Nutzer Leader dieses Meetups (meetup_user.is_leader = true)?
* Nur Leader (und der Ersteller/Super-Admin) dürfen Stammdaten bearbeiten
* und weitere Leader einsetzen/entziehen.
*/
public function isLeader(User $user): bool
{
return $this->users()
->whereKey($user->id)
->wherePivot('is_leader', true)
->exists();
}
/**
* Den Nutzer als Mitglied (nicht Leader) zu „Meine Meetups" hinzufügen.
* Idempotent: ein bereits hinzugefügter Nutzer bleibt unverändert. Gibt
@@ -233,6 +246,32 @@ class Meetup extends Model implements HasMedia
});
}
/**
* Meetups, die der Nutzer als Leader führt (meetup_user.is_leader = true).
* Maßgeblich dafür, wer Stammdaten UND Termine bearbeiten darf.
*/
public function scopeLedBy(Builder $query, int $userId): void
{
$query->whereHas('users', fn (Builder $user) => $user->whereKey($userId)->wherePivot('is_leader', true));
}
/**
* Führt der eingeloggte Nutzer dieses Meetup als Leader? Steuert die
* Sichtbarkeit der Bearbeiten-/Termin-Affordances im Portal-Frontend
* (Gegenstück zu {@see belongsToMe()}, aber leader- statt mitgliedschafts-
* basiert).
*/
protected function leadByMe(): Attribute
{
return Attribute::make(
get: fn (): bool => auth()->check() && DB::table('meetup_user')
->where('meetup_id', $this->id)
->where('user_id', auth()->id())
->where('is_leader', true)
->exists()
);
}
public function city(): BelongsTo
{
return $this->belongsTo(City::class);
+1 -1
View File
@@ -105,7 +105,7 @@ class User extends Authenticatable implements CipherSweetEncrypted
public function meetups()
{
return $this->belongsToMany(Meetup::class);
return $this->belongsToMany(Meetup::class)->withPivot('is_leader');
}
public function reputations()