mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 04:30:31 +00:00
✨ 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:
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user