From ffcee850ca35773b9a56fccf8e6cadff89a7037b Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:11:24 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20leader=20management=20functio?= =?UTF-8?q?nality=20for=20Meetups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ➕ 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. --- .../Api/MeetupLeaderController.php | 12 +- .../Requests/Api/StoreMeetupLeaderRequest.php | 16 +-- app/Models/Meetup.php | 40 ++++++ app/Rules/ValidNpub.php | 30 ++++ .../views/livewire/meetups/edit.blade.php | 133 +++++++++++++++++- .../Feature/Meetups/ManageLeadersWebTest.php | 85 +++++++++++ 6 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 app/Rules/ValidNpub.php create mode 100644 tests/Feature/Meetups/ManageLeadersWebTest.php diff --git a/app/Http/Controllers/Api/MeetupLeaderController.php b/app/Http/Controllers/Api/MeetupLeaderController.php index 88f44ee..34549b7 100644 --- a/app/Http/Controllers/Api/MeetupLeaderController.php +++ b/app/Http/Controllers/Api/MeetupLeaderController.php @@ -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(); } } diff --git a/app/Http/Requests/Api/StoreMeetupLeaderRequest.php b/app/Http/Requests/Api/StoreMeetupLeaderRequest.php index ab4342b..f61119f 100644 --- a/app/Http/Requests/Api/StoreMeetupLeaderRequest.php +++ b/app/Http/Requests/Api/StoreMeetupLeaderRequest.php @@ -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], ]; } } diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index 78148a2..33102a2 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -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 + */ + 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 diff --git a/app/Rules/ValidNpub.php b/app/Rules/ValidNpub.php new file mode 100644 index 0000000..8963bd3 --- /dev/null +++ b/app/Rules/ValidNpub.php @@ -0,0 +1,30 @@ +convertToHex($value); + } catch (\Throwable) { + $fail(__('Das ist kein gültiger npub.')); + } + } +} diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php index 6d24bcf..154f780 100644 --- a/resources/views/livewire/meetups/edit.blade.php +++ b/resources/views/livewire/meetups/edit.blade.php @@ -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 { + + + {{ __('Leader verwalten') }} + +
+
+ + {{ __('Was ist ein Leader?') }} +
+ + {{ __('Leader dürfen dieses Meetup bearbeiten und selbst weitere Leader einsetzen. Setze nur Personen ein, denen du vertraust.') }} + + + {{ __('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…“.') }} + +
+ +
+ + {{ __('npub der Person') }} + + + + + {{ __('Als Leader einsetzen') }} + +
+ + @if (session('leaderStatus')) + {{ session('leaderStatus') }} + @endif + +
+ {{ __('Aktuelle Leader') }} + @foreach ($this->leaders as $leader) +
+ +
+ {{ $leader->name }} + @if ($leader->nostr) + {{ $leader->nostr }} + @endif +
+ @if ($leader->id === $meetup->created_by) + {{ __('Ersteller') }} + @else + + {{ __('Entziehen') }} + + @endif +
+ @endforeach +
+
+
diff --git a/tests/Feature/Meetups/ManageLeadersWebTest.php b/tests/Feature/Meetups/ManageLeadersWebTest.php new file mode 100644 index 0000000..4da4270 --- /dev/null +++ b/tests/Feature/Meetups/ManageLeadersWebTest.php @@ -0,0 +1,85 @@ +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); +});