From 3a507cced2287686ffccb9e7e9aa55605c211b54 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:59:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Enhance=20meetup=20association=20an?= =?UTF-8?q?d=20permissions=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔍 Added `resolveInScope` method to `ResolvesEntities` for scoped entity resolution with stricter control. - 👥 Introduced `AddMeetupToMineTool` MCP tool for adding external meetups to "My Meetups." - 🛠️ Updated `ListMyMeetupsTool` and `ShowMyMeetupTool` to include both created and joined meetups. - 📚 Updated `Meetup` model with `associatedWith` scope for querying user-related meetups. - ✅ Expanded feature tests for meetup membership, creator permissions, and scoped tool usage. - 🛡️ Unified access checks across Livewire and APIs to restrict editing meetup details to creators or super-admins. - 🔗 Registered `AddMeetupToMineTool` in `EinundzwanzigServer`. --- app/Mcp/Servers/EinundzwanzigServer.php | 2 + app/Mcp/Tools/Concerns/ResolvesEntities.php | 34 +++++++++ app/Mcp/Tools/Meetup/AddMeetupToMineTool.php | 66 +++++++++++++++++ app/Mcp/Tools/Meetup/ListMyMeetupsTool.php | 4 +- app/Mcp/Tools/Meetup/ShowMyMeetupTool.php | 22 +++--- .../MeetupEvent/CreateMeetupEventTool.php | 7 +- app/Models/Meetup.php | 24 +++++++ .../views/livewire/meetups/create.blade.php | 6 +- .../views/livewire/meetups/edit.blade.php | 19 ++--- tests/Feature/Livewire/MeetupMountTest.php | 37 +++++----- tests/Feature/Mcp/EinundzwanzigServerTest.php | 2 +- tests/Feature/Mcp/MeetupMembershipMcpTest.php | 72 +++++++++++++++++++ tests/Feature/Meetups/EditMeetupTest.php | 21 +++--- 13 files changed, 260 insertions(+), 56 deletions(-) create mode 100644 app/Mcp/Tools/Meetup/AddMeetupToMineTool.php create mode 100644 tests/Feature/Mcp/MeetupMembershipMcpTest.php diff --git a/app/Mcp/Servers/EinundzwanzigServer.php b/app/Mcp/Servers/EinundzwanzigServer.php index 2e114c1..efc25f4 100644 --- a/app/Mcp/Servers/EinundzwanzigServer.php +++ b/app/Mcp/Servers/EinundzwanzigServer.php @@ -15,6 +15,7 @@ use App\Mcp\Tools\Lecturer\CreateLecturerTool; use App\Mcp\Tools\Lecturer\ListMyLecturersTool; use App\Mcp\Tools\Lecturer\ShowMyLecturerTool; use App\Mcp\Tools\Lecturer\UpdateLecturerTool; +use App\Mcp\Tools\Meetup\AddMeetupToMineTool; use App\Mcp\Tools\Meetup\CreateMeetupTool; use App\Mcp\Tools\Meetup\ListMyMeetupsTool; use App\Mcp\Tools\Meetup\ShowMyMeetupTool; @@ -93,6 +94,7 @@ class EinundzwanzigServer extends Server // Meetups CreateMeetupTool::class, UpdateMeetupTool::class, + AddMeetupToMineTool::class, ListMyMeetupsTool::class, ShowMyMeetupTool::class, diff --git a/app/Mcp/Tools/Concerns/ResolvesEntities.php b/app/Mcp/Tools/Concerns/ResolvesEntities.php index 5b3d440..b614e78 100644 --- a/app/Mcp/Tools/Concerns/ResolvesEntities.php +++ b/app/Mcp/Tools/Concerns/ResolvesEntities.php @@ -54,6 +54,40 @@ trait ResolvesEntities return $this->optionsError($owned, $label, $column); } + /** + * Löst einen Datensatz per ID oder Name STRIKT innerhalb des übergebenen Scopes auf + * (der Scope ist zugleich die Autorisierung). Bei Mehrdeutigkeit oder fehlendem Treffer + * wird eine Auswahlliste der Einträge des Scopes zurückgegeben. + */ + protected function resolveInScope(Builder $scope, Request $request, string $label, string $nameParam, string $column = 'name'): Model|Response + { + $id = $request->get('id'); + + if ($this->present($id)) { + $byId = (clone $scope)->whereKey($id)->first(); + + if ($byId !== null) { + return $byId; + } + } + + $name = $request->get($nameParam); + + if ($this->present($name)) { + $matches = $this->matchByName(clone $scope, (string) $name, $column); + + if ($matches->count() === 1) { + return $matches->first(); + } + + if ($matches->count() > 1) { + return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->join('; ').'. Bitte den genauen Namen angeben.'); + } + } + + return $this->optionsError(clone $scope, $label, $column); + } + /** * Löst einen Fremdschlüssel über den Namen auf und schreibt die ID in den Request, * damit die nachgelagerte Validierung sie sieht. Gibt null zurück, wenn nichts zu tun diff --git a/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php b/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php new file mode 100644 index 0000000..c00fcf7 --- /dev/null +++ b/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php @@ -0,0 +1,66 @@ +user(); + + if ($user === null) { + return Response::error('Nicht authentifiziert.'); + } + + if ($this->present($request->get('meetup_id'))) { + $meetup = Meetup::find($request->get('meetup_id')); + + if ($meetup === null) { + return Response::error('Meetup nicht gefunden.'); + } + } else { + $meetup = $this->resolveGlobalByName(Meetup::query(), $request->get('meetup'), 'Meetups'); + + if ($meetup instanceof Response) { + return $meetup; + } + } + + $alreadyMember = $meetup->users()->whereKey($user->getAuthIdentifier())->exists(); + + $meetup->users()->syncWithoutDetaching([ + $user->getAuthIdentifier() => ['is_leader' => false], + ]); + + return Response::json([ + 'meetup' => MeetupResource::make($meetup)->resolve(), + 'message' => $alreadyMember + ? '„'.$meetup->name.'" war bereits Teil deiner Meetups.' + : '„'.$meetup->name.'" wurde zu deinen Meetups hinzugefügt.', + ]); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'meetup' => $schema->string()->description('Name des bestehenden Meetups, das hinzugefügt werden soll (vorher per search-meetups ermitteln).'), + 'meetup_id' => $schema->integer()->description('Optional: ID des Meetups, falls bereits bekannt (Alternative zu "meetup").'), + ]; + } +} diff --git a/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php b/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php index b28e35e..e82c830 100644 --- a/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php +++ b/app/Mcp/Tools/Meetup/ListMyMeetupsTool.php @@ -12,7 +12,7 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Listet alle vom authentifizierten Nutzer erstellten Meetups, alphabetisch sortiert.')] +#[Description('Listet die Meetups des authentifizierten Nutzers (selbst erstellte UND beigetretene), alphabetisch sortiert.')] class ListMyMeetupsTool extends Tool { public function handle(Request $request): Response @@ -24,7 +24,7 @@ class ListMyMeetupsTool extends Tool } $meetups = Meetup::query() - ->where('created_by', $user->getAuthIdentifier()) + ->associatedWith($user->getAuthIdentifier()) ->orderBy('name') ->get(); diff --git a/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php index c0a2d4f..a4bb34b 100644 --- a/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php +++ b/app/Mcp/Tools/Meetup/ShowMyMeetupTool.php @@ -7,7 +7,6 @@ use App\Mcp\Tools\Concerns\ResolvesEntities; use App\Models\Meetup; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; -use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; @@ -15,25 +14,30 @@ use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Zeigt eines deiner Meetups (per Name angegeben).')] +#[Description('Zeigt eines deiner Meetups (selbst erstellt oder beigetreten, per Name angegeben).')] class ShowMyMeetupTool extends Tool { use ResolvesEntities; public function handle(Request $request): Response { - $meetup = $this->resolveOwnedByName($request, Meetup::class, 'Meetups', 'meetup'); + $user = $request->user(); + + if ($user === null) { + return Response::error('Nicht authentifiziert.'); + } + + $meetup = $this->resolveInScope( + Meetup::query()->associatedWith($user->getAuthIdentifier()), + $request, + 'Meetups', + 'meetup', + ); if ($meetup instanceof Response) { return $meetup; } - $user = $request->user(); - - if ($user === null || Gate::forUser($user)->denies('view', $meetup)) { - return Response::error('Nur der Ersteller oder ein Super-Admin darf dieses Meetup sehen.'); - } - return Response::json(MeetupResource::make($meetup)->resolve()); } diff --git a/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php index 5cd2e9d..3a4b928 100644 --- a/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php +++ b/app/Mcp/Tools/MeetupEvent/CreateMeetupEventTool.php @@ -29,7 +29,12 @@ class CreateMeetupEventTool extends Tool } if (! $this->present($request->get('meetup_id'))) { - $meetup = $this->resolveOwnedByName($request, Meetup::class, 'Meetups', 'meetup'); + $meetup = $this->resolveInScope( + Meetup::query()->associatedWith($user->getAuthIdentifier()), + $request, + 'Meetups', + 'meetup', + ); if ($meetup instanceof Response) { return $meetup; diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index 85d6272..d02ba95 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -67,6 +68,17 @@ class Meetup extends Model implements HasMedia $model->created_by = auth()->id(); } }); + + // Der Ersteller wird automatisch als Leiter in die meetup_user-Pivot eingetragen, + // damit das Meetup einheitlich (MCP, REST-API, Livewire) in „Meine Meetups" + // erscheint – egal über welchen Pfad es angelegt wurde. + static::created(function (Meetup $model): void { + if ($model->created_by !== null) { + $model->users()->syncWithoutDetaching([ + $model->created_by => ['is_leader' => true], + ]); + } + }); } public function getSlugOptions(): SlugOptions @@ -109,6 +121,18 @@ class Meetup extends Model implements HasMedia return $this->belongsToMany(User::class); } + /** + * Meetups, die dem Nutzer zugeordnet sind: selbst erstellt (created_by) ODER + * Mitglied über die meetup_user-Pivot. Entspricht „Meine Meetups" im Portal. + */ + public function scopeAssociatedWith(Builder $query, int $userId): void + { + $query->where(function (Builder $inner) use ($userId): void { + $inner->where('created_by', $userId) + ->orWhereHas('users', fn (Builder $user) => $user->whereKey($userId)); + }); + } + public function city(): BelongsTo { return $this->belongsTo(City::class); diff --git a/resources/views/livewire/meetups/create.blade.php b/resources/views/livewire/meetups/create.blade.php index 0e16f63..bef19a1 100644 --- a/resources/views/livewire/meetups/create.blade.php +++ b/resources/views/livewire/meetups/create.blade.php @@ -94,10 +94,8 @@ class extends Component { $meetup = Meetup::create($validated + ['created_by' => auth()->id()]); - // Attach the creator to meetup_user so they appear under "My-Meetups" - // and pass the new edit-permission check (which is based on this pivot, - // not on created_by). - $meetup->users()->attach(auth()->id()); + // Der Ersteller wird über das Meetup::created-Model-Event automatisch als Leiter + // in die meetup_user-Pivot eingetragen (einheitlich mit MCP und REST-API). if ($this->logo) { $meetup diff --git a/resources/views/livewire/meetups/edit.blade.php b/resources/views/livewire/meetups/edit.blade.php index f28a971..19d0ce8 100644 --- a/resources/views/livewire/meetups/edit.blade.php +++ b/resources/views/livewire/meetups/edit.blade.php @@ -84,23 +84,14 @@ class extends Component { } /** - * Enforce that only users who have added the meetup to their personal - * "My-Meetups" list (the meetup_user pivot) may load or update this view. - * Editing is intentionally not restricted to the original `created_by` - * — any member of the meetup's user list is treated as an editor. + * Stammdaten eines Meetups dürfen ausschließlich vom Ersteller (created_by) oder + * einem Super-Admin bearbeitet werden – einheitlich mit MeetupPolicy, der REST-API + * und den MCP-Tools. Reine Mitglieder (meetup_user-Pivot) dürfen nur Termine anlegen + * (siehe meetups.create-edit-events), nicht aber die Stammdaten ändern. */ protected function authorizeAccess(): void { - if (! auth()->check()) { - abort(403); - } - - $isMember = $this->meetup - ->users() - ->whereKey(auth()->id()) - ->exists(); - - if (! $isMember) { + if (auth()->guest() || auth()->user()->cannot('update', $this->meetup)) { abort(403); } } diff --git a/tests/Feature/Livewire/MeetupMountTest.php b/tests/Feature/Livewire/MeetupMountTest.php index 776d63c..a30eb3c 100644 --- a/tests/Feature/Livewire/MeetupMountTest.php +++ b/tests/Feature/Livewire/MeetupMountTest.php @@ -30,15 +30,28 @@ it('mounts meetups.create when authenticated', function () { Livewire::test('meetups.create')->assertStatus(200); }); -it('mounts meetups.edit when the authenticated user has added the meetup to My-Meetups', function () { - $owner = actingAsUser(); - $meetup = Meetup::factory()->create(['city_id' => $this->city->id]); - $meetup->users()->attach($owner); +it('mounts meetups.edit for the creator of the meetup', function () { + $creator = actingAsUser(); + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'created_by' => $creator->id, + ]); + $meetup->users()->attach($creator); Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200); }); -it('mounts meetups.edit for a My-Meetups member even if another user created the meetup', function () { +it('mounts meetups.edit for the creator even without a My-Meetups pivot entry', function () { + $creator = actingAsUser(); + $meetup = Meetup::factory()->create([ + 'city_id' => $this->city->id, + 'created_by' => $creator->id, + ]); + + Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200); +}); + +it('aborts meetups.edit with 403 for a member who did not create the meetup', function () { $creator = User::factory()->create(); $member = actingAsUser(); $meetup = Meetup::factory()->create([ @@ -47,25 +60,15 @@ it('mounts meetups.edit for a My-Meetups member even if another user created the ]); $meetup->users()->attach($member); - Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200); + Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(403); }); -it('aborts meetups.edit with 403 when the authenticated user has not added the meetup to My-Meetups', function () { +it('aborts meetups.edit with 403 when the user is neither creator nor super-admin', function () { actingAsUser(); Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(403); }); -it('aborts meetups.edit with 403 when the authenticated user is only the creator but not in My-Meetups', function () { - $creator = actingAsUser(); - $meetup = Meetup::factory()->create([ - 'city_id' => $this->city->id, - 'created_by' => $creator->id, - ]); - - Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(403); -}); - it('mounts meetups.create-edit-events for new event', function () { actingAsUser(); Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200); diff --git a/tests/Feature/Mcp/EinundzwanzigServerTest.php b/tests/Feature/Mcp/EinundzwanzigServerTest.php index 7137107..6ae4e96 100644 --- a/tests/Feature/Mcp/EinundzwanzigServerTest.php +++ b/tests/Feature/Mcp/EinundzwanzigServerTest.php @@ -17,7 +17,7 @@ it('registers every domain tool on the server', function () { $property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools'); $tools = $property->getDefaultValue(); - expect($tools)->toHaveCount(31) + expect($tools)->toHaveCount(32) ->and($tools)->toContain(CreateMeetupTool::class) ->and($tools)->toContain(UpdateCourseEventTool::class) ->and($tools)->toContain(SearchCitiesTool::class); diff --git a/tests/Feature/Mcp/MeetupMembershipMcpTest.php b/tests/Feature/Mcp/MeetupMembershipMcpTest.php new file mode 100644 index 0000000..76766c4 --- /dev/null +++ b/tests/Feature/Mcp/MeetupMembershipMcpTest.php @@ -0,0 +1,72 @@ +create(); + $meetup = Meetup::factory()->create(['name' => 'Einundzwanzig Dortmund', 'created_by' => $owner->id]); + $user = User::factory()->create(); + + EinundzwanzigServer::actingAs($user) + ->tool(AddMeetupToMineTool::class, ['meetup' => 'Einundzwanzig Dortmund']) + ->assertOk() + ->assertSee('hinzugefügt'); + + $this->assertDatabaseHas('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $user->id, + 'is_leader' => false, + ]); +}); + +it('lists joined meetups (not only created ones) in my meetups', function () { + $user = User::factory()->create(); + $joined = Meetup::factory()->create(['name' => 'Einundzwanzig Dortmund']); + $joined->users()->attach($user->id, ['is_leader' => false]); + + $response = EinundzwanzigServer::actingAs($user)->tool(ListMyMeetupsTool::class); + + $response->assertOk()->assertSee('Einundzwanzig Dortmund'); +}); + +it('makes the creator a leader so the meetup shows in my meetups', function () { + $user = User::factory()->create(); + City::factory()->create(['name' => 'Ansbach']); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupTool::class, ['name' => 'Einundzwanzig Ansbach', 'city' => 'Ansbach']) + ->assertOk(); + + $meetup = Meetup::query()->where('name', 'Einundzwanzig Ansbach')->sole(); + + $this->assertDatabaseHas('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $user->id, + 'is_leader' => true, + ]); +}); + +it('lets a member add an event to a joined meetup', function () { + $user = User::factory()->create(); + $meetup = Meetup::factory()->create(['name' => 'Einundzwanzig Dortmund']); + $meetup->users()->attach($user->id, ['is_leader' => false]); + + EinundzwanzigServer::actingAs($user) + ->tool(CreateMeetupEventTool::class, [ + 'meetup' => 'Einundzwanzig Dortmund', + 'start' => '2026-08-01 18:00:00', + ]) + ->assertOk(); + + $this->assertDatabaseHas('meetup_events', [ + 'meetup_id' => $meetup->id, + 'created_by' => $user->id, + ]); +}); diff --git a/tests/Feature/Meetups/EditMeetupTest.php b/tests/Feature/Meetups/EditMeetupTest.php index b299908..75569e4 100644 --- a/tests/Feature/Meetups/EditMeetupTest.php +++ b/tests/Feature/Meetups/EditMeetupTest.php @@ -3,6 +3,7 @@ use App\Models\City; use App\Models\Country; use App\Models\Meetup; +use App\Models\User; use Livewire\Livewire; beforeEach(function () { @@ -10,13 +11,14 @@ beforeEach(function () { $this->city = City::factory()->create(['country_id' => $country->id]); }); -it('updates an existing Meetup name when the user has it in My-Meetups', function () { - $member = actingAsUser(); +it('updates an existing Meetup name as the creator', function () { + $creator = actingAsUser(); $meetup = Meetup::factory()->create([ 'city_id' => $this->city->id, 'name' => 'Original Name', + 'created_by' => $creator->id, ]); - $meetup->users()->attach($member); + $meetup->users()->attach($creator); Livewire::test('meetups.edit', ['meetup' => $meetup]) ->set('name', 'Updated Name') @@ -29,12 +31,13 @@ it('updates an existing Meetup name when the user has it in My-Meetups', functio }); it('rejects update when name collides with another existing Meetup', function () { - $member = actingAsUser(); + $creator = actingAsUser(); $meetup = Meetup::factory()->create([ 'city_id' => $this->city->id, 'name' => 'Original Name', + 'created_by' => $creator->id, ]); - $meetup->users()->attach($member); + $meetup->users()->attach($creator); Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]); Livewire::test('meetups.edit', ['meetup' => $meetup]) @@ -44,12 +47,13 @@ it('rejects update when name collides with another existing Meetup', function () }); it('allows update when name is unchanged (Rule::unique ignores own id)', function () { - $member = actingAsUser(); + $creator = actingAsUser(); $meetup = Meetup::factory()->create([ 'city_id' => $this->city->id, 'name' => 'Original Name', + 'created_by' => $creator->id, ]); - $meetup->users()->attach($member); + $meetup->users()->attach($creator); Livewire::test('meetups.edit', ['meetup' => $meetup]) ->set('name', 'Original Name') @@ -58,11 +62,12 @@ it('allows update when name is unchanged (Rule::unique ignores own id)', functio ->assertHasNoErrors(); }); -it('blocks updateMeetup when the user has not added the meetup to My-Meetups', function () { +it('blocks updateMeetup when the user is not the creator', function () { actingAsUser(); $meetup = Meetup::factory()->create([ 'city_id' => $this->city->id, 'name' => 'Original Name', + 'created_by' => User::factory()->create()->id, ]); Livewire::test('meetups.edit', ['meetup' => $meetup])