Add leader management functionality for Meetups

-  Introduced `leaders`, `promoteLeader`, and `demoteLeader` methods in `Meetup` model for consistent handling of meetup leadership.
- ⚙️ Refactored `MeetupLeaderController` to use new leadership methods, improving reusability and maintainability.
- 👮‍♂️ Added `ValidNpub` validation rule for npub input standardization.
- 🧪 Added feature tests for leadership delegation and permissions.
- 🖼️ Implemented leader management UI in meetup edit page with flash messaging for actions.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-16 23:11:24 +02:00
parent 9f8fda294a
commit ffcee850ca
6 changed files with 289 additions and 27 deletions
@@ -48,9 +48,7 @@ class MeetupLeaderController extends Controller
{
$user = NostrLogin::findOrCreateUser($request->string('npub')->toString());
$meetup->users()->syncWithoutDetaching([
$user->getKey() => ['is_leader' => true],
]);
$meetup->promoteLeader($user);
return response()->json(['data' => $this->leaders($meetup)], HttpResponse::HTTP_CREATED);
}
@@ -69,7 +67,7 @@ class MeetupLeaderController extends Controller
abort_if($user->getKey() === $meetup->created_by, HttpResponse::HTTP_FORBIDDEN, __('Der Ersteller des Meetups kann nicht entzogen werden.'));
$meetup->users()->updateExistingPivot($user->getKey(), ['is_leader' => false]);
$meetup->demoteLeader($user);
return response()->json(['data' => $this->leaders($meetup)]);
}
@@ -81,9 +79,7 @@ class MeetupLeaderController extends Controller
*/
private function leaders(Meetup $meetup): array
{
return $meetup->users()
->wherePivot('is_leader', true)
->get()
return $meetup->leaders()
->map(fn (User $user): array => [
'id' => $user->getKey(),
'name' => $user->name,
@@ -91,8 +87,6 @@ class MeetupLeaderController extends Controller
'avatar' => $user->profile_photo_url,
'is_creator' => $user->getKey() === $meetup->created_by,
])
->sortByDesc('is_creator')
->values()
->all();
}
}
@@ -2,9 +2,8 @@
namespace App\Http\Requests\Api;
use Closure;
use App\Rules\ValidNpub;
use Illuminate\Foundation\Http\FormRequest;
use swentel\nostr\Key\Key as NostrKey;
/**
* Setzt einen weiteren Leader für ein Meetup per Nostr-npub ein. Nur ein
@@ -24,18 +23,7 @@ class StoreMeetupLeaderRequest extends FormRequest
public function rules(): array
{
return [
'npub' => [
'required',
'string',
'starts_with:npub1',
function (string $attribute, mixed $value, Closure $fail): void {
try {
(new NostrKey)->convertToHex((string) $value);
} catch (\Throwable) {
$fail(__('Das ist kein gültiger npub.'));
}
},
],
'npub' => ['required', 'string', new ValidNpub],
];
}
}
+40
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@@ -198,6 +199,45 @@ class Meetup extends Model implements HasMedia
->exists();
}
/**
* Aktuelle Leader dieses Meetups (meetup_user.is_leader = true), Ersteller
* zuerst. Gemeinsame Quelle für REST-API (MeetupLeaderController) und das
* Portal-Frontend (meetups.edit).
*
* @return Collection<int, User>
*/
public function leaders(): Collection
{
return $this->users()
->wherePivot('is_leader', true)
->get()
->sortByDesc(fn (User $user): bool => $user->getKey() === $this->created_by)
->values();
}
/**
* Nutzer zum Leader befördern (meetup_user.is_leader = true). Idempotent.
* Geteilt von REST-Controller, MCP-Tools und Portal-Frontend.
*/
public function promoteLeader(User $user): void
{
$this->users()->syncWithoutDetaching([$user->getKey() => ['is_leader' => true]]);
}
/**
* Nutzer die Leader-Rolle entziehen (Demote; bleibt Mitglied). Der Ersteller
* des Meetups ist geschützt und wird nie entzogen (Domain-Invariante)
* Aufrufer dürfen zusätzlich mit 403 antworten.
*/
public function demoteLeader(User $user): void
{
if ($user->getKey() === $this->created_by) {
return;
}
$this->users()->updateExistingPivot($user->getKey(), ['is_leader' => false]);
}
/**
* Den Nutzer als Mitglied (nicht Leader) zu „Meine Meetups" hinzufügen.
* Idempotent: ein bereits hinzugefügter Nutzer bleibt unverändert. Gibt
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use swentel\nostr\Key\Key as NostrKey;
/**
* Validiert einen Nostr-npub (bech32-kodierter öffentlicher Schlüssel).
* Geteilt von der REST-API (StoreMeetupLeaderRequest) und der Leader-
* Verwaltung im Portal-Frontend, damit beide Wege dieselbe Prüfung nutzen.
*/
class ValidNpub implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! str_starts_with($value, 'npub1')) {
$fail(__('Das ist kein gültiger npub.'));
return;
}
try {
(new NostrKey)->convertToHex($value);
} catch (\Throwable) {
$fail(__('Das ist kein gültiger npub.'));
}
}
}
+129 -4
View File
@@ -4,9 +4,14 @@ use App\Attributes\SeoDataAttribute;
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\User;
use App\Rules\ValidNpub;
use App\Support\NostrLogin;
use App\Traits\SeoTrait;
use Flux\Flux;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -14,9 +19,10 @@ use Livewire\WithFileUploads;
new
#[SeoDataAttribute(key: 'meetups_edit')]
class extends Component {
use WithFileUploads;
class extends Component
{
use SeoTrait;
use WithFileUploads;
#[Validate('image|mimes:jpeg,png,webp,avif|max:5120|dimensions:max_width=4000,max_height=4000')]
public $logo;
@@ -25,23 +31,35 @@ class extends Component {
// Basic Information
public string $name = '';
public ?int $city_id = null;
public string $slug = '';
public ?string $intro = null;
// Links and Social Media
public ?string $telegram_link = null;
public ?string $webpage = null;
public ?string $twitter_username = null;
public ?string $matrix_group = null;
public ?string $nostr = null;
public ?string $nostr_status = null;
public ?string $simplex = null;
public ?string $signal = null;
// Additional Information
public ?string $community = null;
public ?string $github_data = null;
public bool $visible_on_map = false;
// System fields (read-only) - locked to prevent client-side tampering
@@ -54,10 +72,16 @@ class extends Component {
#[Locked]
public ?string $updated_at = null;
// Leader management (Delegation): npub des einzusetzenden Leaders.
public string $leaderNpub = '';
// New City Modal
public string $newCityName = '';
public ?int $newCityCountryId = null;
public ?float $newCityLatitude = null;
public ?float $newCityLongitude = null;
public function createCity(): void
@@ -81,7 +105,7 @@ class extends Component {
$this->city_id = $city->id;
$this->reset(['newCityName', 'newCityCountryId', 'newCityLatitude', 'newCityLongitude']);
\Flux\Flux::modal('add-city')->close();
Flux::modal('add-city')->close();
}
/**
@@ -96,6 +120,49 @@ class extends Component {
}
}
/**
* Aktuelle Leader dieses Meetups (meetup_user.is_leader = true), Ersteller
* zuerst. Treibt die Leader-Verwaltungsliste auf der Bearbeiten-Seite.
*/
#[Computed]
public function leaders()
{
return $this->meetup->leaders();
}
/**
* Einen weiteren Leader per npub einsetzen. Nur Leader/Ersteller dürfen das
* (manageLeaders). Existiert noch kein Account für den npub, wird er angelegt
* (greift beim ersten Login). Idempotent: ein bereits gesetzter Leader bleibt.
*/
public function addLeader(): void
{
abort_unless(auth()->user()?->can('manageLeaders', $this->meetup), 403);
$validated = $this->validate(['leaderNpub' => ['required', 'string', new ValidNpub]]);
$this->meetup->promoteLeader(NostrLogin::findOrCreateUser($validated['leaderNpub']));
$this->leaderNpub = '';
unset($this->leaders);
session()->flash('leaderStatus', __('Leader eingesetzt.'));
}
/**
* Einem Nutzer die Leader-Rolle entziehen (Demote; bleibt Mitglied). Der
* Ersteller des Meetups ist geschützt und kann nie entzogen werden.
*/
public function removeLeader(int $userId): void
{
abort_unless(auth()->user()?->can('manageLeaders', $this->meetup), 403);
abort_if($userId === $this->meetup->created_by, 403, __('Der Ersteller des Meetups kann nicht entzogen werden.'));
$this->meetup->demoteLeader(User::findOrFail($userId));
unset($this->leaders);
session()->flash('leaderStatus', __('Leader entzogen.'));
}
/**
* Whitelist the keys allowed inside github_data and coerce types so a
* tampered payload cannot smuggle arbitrary keys into the stored JSON.
@@ -107,7 +174,7 @@ class extends Component {
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
if (! is_array($decoded)) {
return null;
}
@@ -439,6 +506,64 @@ class extends Component {
</div>
</form>
<!-- Leader-Verwaltung (Delegation) -->
<flux:fieldset class="space-y-6 mt-10 pt-10 border-t border-gray-200 dark:border-gray-700">
<flux:legend>{{ __('Leader verwalten') }}</flux:legend>
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 space-y-2 dark:border-amber-500/40 dark:bg-amber-500/10">
<div class="flex items-center gap-2 font-semibold text-amber-800 dark:text-amber-300">
<flux:icon name="information-circle" class="size-5"/>
{{ __('Was ist ein Leader?') }}
</div>
<flux:text>
{{ __('Leader dürfen dieses Meetup bearbeiten und selbst weitere Leader einsetzen. Setze nur Personen ein, denen du vertraust.') }}
</flux:text>
<flux:text>
{{ __('Frag die Person nach ihrem Nostr-Schlüssel (npub). Sie findet ihn in ihrer Nostr-App oder in ihrem Portal-Profil — er beginnt mit „npub1…“.') }}
</flux:text>
</div>
<form wire:submit="addLeader" class="flex flex-col gap-3 sm:flex-row sm:items-end">
<flux:field class="flex-1">
<flux:label>{{ __('npub der Person') }}</flux:label>
<flux:input wire:model="leaderNpub" placeholder="npub1…"/>
<flux:error name="leaderNpub"/>
</flux:field>
<flux:button class="cursor-pointer" type="submit" variant="primary" icon="user-plus">
{{ __('Als Leader einsetzen') }}
</flux:button>
</form>
@if (session('leaderStatus'))
<flux:text class="font-medium text-green-600 dark:text-green-400">{{ session('leaderStatus') }}</flux:text>
@endif
<div class="space-y-2">
<flux:text class="font-medium">{{ __('Aktuelle Leader') }}</flux:text>
@foreach ($this->leaders as $leader)
<div class="flex items-center gap-3 rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"
wire:key="leader-{{ $leader->id }}">
<flux:avatar size="sm" circle :name="$leader->name" src="{{ $leader->profile_photo_url }}"/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-semibold">{{ $leader->name }}</span>
@if ($leader->nostr)
<span class="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">{{ $leader->nostr }}</span>
@endif
</div>
@if ($leader->id === $meetup->created_by)
<flux:badge color="amber" icon="star">{{ __('Ersteller') }}</flux:badge>
@else
<flux:button class="cursor-pointer" size="sm" variant="ghost" icon="user-minus"
wire:click="removeLeader({{ $leader->id }})"
wire:confirm="{{ __(':name die Leader-Rolle entziehen? Die Person bleibt Mitglied, kann das Meetup aber nicht mehr bearbeiten.', ['name' => $leader->name]) }}">
{{ __('Entziehen') }}
</flux:button>
@endif
</div>
@endforeach
</div>
</flux:fieldset>
<!-- Add City Modal -->
<flux:modal name="add-city" variant="flyout" wire:key="add-city-modal">
<form wire:submit="createCity" class="space-y-6">
@@ -0,0 +1,85 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\User;
use Livewire\Livewire;
use swentel\nostr\Key\Key as NostrKey;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
$this->creator = actingAsUser();
$this->meetup = Meetup::factory()->create([
'city_id' => $this->city->id,
'created_by' => $this->creator->id,
]);
});
function webNpub(string $hex): string
{
return (new NostrKey)->convertPublicKeyToBech32($hex);
}
it('appoints a leader by npub from the edit page', function () {
$npub = webNpub(str_pad('a', 64, 'a'));
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->set('leaderNpub', $npub)
->call('addLeader')
->assertHasNoErrors();
$newUser = User::where('nostr', $npub)->firstOrFail();
$this->assertDatabaseHas('meetup_user', [
'meetup_id' => $this->meetup->id,
'user_id' => $newUser->id,
'is_leader' => true,
]);
});
it('rejects an invalid npub on the edit page', function () {
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->set('leaderNpub', 'not-an-npub')
->call('addLeader')
->assertHasErrors('leaderNpub');
});
it('demotes a leader but keeps the membership', function () {
$leader = User::factory()->create();
$this->meetup->users()->syncWithoutDetaching([$leader->id => ['is_leader' => true]]);
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->call('removeLeader', $leader->id)
->assertHasNoErrors();
$this->assertDatabaseHas('meetup_user', [
'meetup_id' => $this->meetup->id,
'user_id' => $leader->id,
'is_leader' => false,
]);
});
it('never lets the creator be demoted', function () {
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->call('removeLeader', $this->creator->id)
->assertStatus(403);
$this->assertDatabaseHas('meetup_user', [
'meetup_id' => $this->meetup->id,
'user_id' => $this->creator->id,
'is_leader' => true,
]);
});
it('forbids a plain member from managing leaders via the edit page', function () {
$member = User::factory()->create();
$this->meetup->addMember($member); // is_leader = false
$this->actingAs($member);
// Nicht-Leader kommen schon nicht auf die Seite (authorizeAccess).
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->assertStatus(403);
});