Enhance meetup association and permissions management

- 🔍 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`.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-08 11:59:02 +02:00
parent dc2b828777
commit 3a507cced2
13 changed files with 260 additions and 56 deletions
+2
View File
@@ -15,6 +15,7 @@ use App\Mcp\Tools\Lecturer\CreateLecturerTool;
use App\Mcp\Tools\Lecturer\ListMyLecturersTool; use App\Mcp\Tools\Lecturer\ListMyLecturersTool;
use App\Mcp\Tools\Lecturer\ShowMyLecturerTool; use App\Mcp\Tools\Lecturer\ShowMyLecturerTool;
use App\Mcp\Tools\Lecturer\UpdateLecturerTool; use App\Mcp\Tools\Lecturer\UpdateLecturerTool;
use App\Mcp\Tools\Meetup\AddMeetupToMineTool;
use App\Mcp\Tools\Meetup\CreateMeetupTool; use App\Mcp\Tools\Meetup\CreateMeetupTool;
use App\Mcp\Tools\Meetup\ListMyMeetupsTool; use App\Mcp\Tools\Meetup\ListMyMeetupsTool;
use App\Mcp\Tools\Meetup\ShowMyMeetupTool; use App\Mcp\Tools\Meetup\ShowMyMeetupTool;
@@ -93,6 +94,7 @@ class EinundzwanzigServer extends Server
// Meetups // Meetups
CreateMeetupTool::class, CreateMeetupTool::class,
UpdateMeetupTool::class, UpdateMeetupTool::class,
AddMeetupToMineTool::class,
ListMyMeetupsTool::class, ListMyMeetupsTool::class,
ShowMyMeetupTool::class, ShowMyMeetupTool::class,
@@ -54,6 +54,40 @@ trait ResolvesEntities
return $this->optionsError($owned, $label, $column); 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, * 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 * damit die nachgelagerte Validierung sie sieht. Gibt null zurück, wenn nichts zu tun
@@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Tools\Meetup;
use App\Http\Resources\MeetupResource;
use App\Mcp\Tools\Concerns\ResolvesEntities;
use App\Models\Meetup;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Fügt ein bereits bestehendes Meetup (per Name angegeben) zu „Meine Meetups" hinzu, sodass es in der eigenen Liste erscheint und Termine dazu angelegt werden können. Macht den Nutzer zum Mitglied die Stammdaten (Name, Stadt, Links) bleiben dem ursprünglichen Ersteller vorbehalten. Vorher mit search-meetups den genauen Namen ermitteln.')]
class AddMeetupToMineTool extends Tool
{
use ResolvesEntities;
public function handle(Request $request): Response
{
$user = $request->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<string, Type>
*/
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").'),
];
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[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 class ListMyMeetupsTool extends Tool
{ {
public function handle(Request $request): Response public function handle(Request $request): Response
@@ -24,7 +24,7 @@ class ListMyMeetupsTool extends Tool
} }
$meetups = Meetup::query() $meetups = Meetup::query()
->where('created_by', $user->getAuthIdentifier()) ->associatedWith($user->getAuthIdentifier())
->orderBy('name') ->orderBy('name')
->get(); ->get();
+13 -9
View File
@@ -7,7 +7,6 @@ use App\Mcp\Tools\Concerns\ResolvesEntities;
use App\Models\Meetup; use App\Models\Meetup;
use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type; use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
use Laravel\Mcp\Response; use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Description;
@@ -15,25 +14,30 @@ use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[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 class ShowMyMeetupTool extends Tool
{ {
use ResolvesEntities; use ResolvesEntities;
public function handle(Request $request): Response 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) { if ($meetup instanceof Response) {
return $meetup; 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()); return Response::json(MeetupResource::make($meetup)->resolve());
} }
@@ -29,7 +29,12 @@ class CreateMeetupEventTool extends Tool
} }
if (! $this->present($request->get('meetup_id'))) { 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) { if ($meetup instanceof Response) {
return $meetup; return $meetup;
+24
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -67,6 +68,17 @@ class Meetup extends Model implements HasMedia
$model->created_by = auth()->id(); $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 public function getSlugOptions(): SlugOptions
@@ -109,6 +121,18 @@ class Meetup extends Model implements HasMedia
return $this->belongsToMany(User::class); 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 public function city(): BelongsTo
{ {
return $this->belongsTo(City::class); return $this->belongsTo(City::class);
@@ -94,10 +94,8 @@ class extends Component {
$meetup = Meetup::create($validated + ['created_by' => auth()->id()]); $meetup = Meetup::create($validated + ['created_by' => auth()->id()]);
// Attach the creator to meetup_user so they appear under "My-Meetups" // Der Ersteller wird über das Meetup::created-Model-Event automatisch als Leiter
// and pass the new edit-permission check (which is based on this pivot, // in die meetup_user-Pivot eingetragen (einheitlich mit MCP und REST-API).
// not on created_by).
$meetup->users()->attach(auth()->id());
if ($this->logo) { if ($this->logo) {
$meetup $meetup
@@ -84,23 +84,14 @@ class extends Component {
} }
/** /**
* Enforce that only users who have added the meetup to their personal * Stammdaten eines Meetups dürfen ausschließlich vom Ersteller (created_by) oder
* "My-Meetups" list (the meetup_user pivot) may load or update this view. * einem Super-Admin bearbeitet werden einheitlich mit MeetupPolicy, der REST-API
* Editing is intentionally not restricted to the original `created_by` * und den MCP-Tools. Reine Mitglieder (meetup_user-Pivot) dürfen nur Termine anlegen
* any member of the meetup's user list is treated as an editor. * (siehe meetups.create-edit-events), nicht aber die Stammdaten ändern.
*/ */
protected function authorizeAccess(): void protected function authorizeAccess(): void
{ {
if (! auth()->check()) { if (auth()->guest() || auth()->user()->cannot('update', $this->meetup)) {
abort(403);
}
$isMember = $this->meetup
->users()
->whereKey(auth()->id())
->exists();
if (! $isMember) {
abort(403); abort(403);
} }
} }
+20 -17
View File
@@ -30,15 +30,28 @@ it('mounts meetups.create when authenticated', function () {
Livewire::test('meetups.create')->assertStatus(200); Livewire::test('meetups.create')->assertStatus(200);
}); });
it('mounts meetups.edit when the authenticated user has added the meetup to My-Meetups', function () { it('mounts meetups.edit for the creator of the meetup', function () {
$owner = actingAsUser(); $creator = actingAsUser();
$meetup = Meetup::factory()->create(['city_id' => $this->city->id]); $meetup = Meetup::factory()->create([
$meetup->users()->attach($owner); 'city_id' => $this->city->id,
'created_by' => $creator->id,
]);
$meetup->users()->attach($creator);
Livewire::test('meetups.edit', ['meetup' => $meetup])->assertStatus(200); 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(); $creator = User::factory()->create();
$member = actingAsUser(); $member = actingAsUser();
$meetup = Meetup::factory()->create([ $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); $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(); actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(403); 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 () { it('mounts meetups.create-edit-events for new event', function () {
actingAsUser(); actingAsUser();
Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200); Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200);
@@ -17,7 +17,7 @@ it('registers every domain tool on the server', function () {
$property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools'); $property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools');
$tools = $property->getDefaultValue(); $tools = $property->getDefaultValue();
expect($tools)->toHaveCount(31) expect($tools)->toHaveCount(32)
->and($tools)->toContain(CreateMeetupTool::class) ->and($tools)->toContain(CreateMeetupTool::class)
->and($tools)->toContain(UpdateCourseEventTool::class) ->and($tools)->toContain(UpdateCourseEventTool::class)
->and($tools)->toContain(SearchCitiesTool::class); ->and($tools)->toContain(SearchCitiesTool::class);
@@ -0,0 +1,72 @@
<?php
use App\Mcp\Servers\EinundzwanzigServer;
use App\Mcp\Tools\Meetup\AddMeetupToMineTool;
use App\Mcp\Tools\Meetup\CreateMeetupTool;
use App\Mcp\Tools\Meetup\ListMyMeetupsTool;
use App\Mcp\Tools\MeetupEvent\CreateMeetupEventTool;
use App\Models\City;
use App\Models\Meetup;
use App\Models\User;
it('adds an existing foreign meetup to my meetups as a member', function () {
$owner = User::factory()->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,
]);
});
+13 -8
View File
@@ -3,6 +3,7 @@
use App\Models\City; use App\Models\City;
use App\Models\Country; use App\Models\Country;
use App\Models\Meetup; use App\Models\Meetup;
use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
beforeEach(function () { beforeEach(function () {
@@ -10,13 +11,14 @@ beforeEach(function () {
$this->city = City::factory()->create(['country_id' => $country->id]); $this->city = City::factory()->create(['country_id' => $country->id]);
}); });
it('updates an existing Meetup name when the user has it in My-Meetups', function () { it('updates an existing Meetup name as the creator', function () {
$member = actingAsUser(); $creator = actingAsUser();
$meetup = Meetup::factory()->create([ $meetup = Meetup::factory()->create([
'city_id' => $this->city->id, 'city_id' => $this->city->id,
'name' => 'Original Name', 'name' => 'Original Name',
'created_by' => $creator->id,
]); ]);
$meetup->users()->attach($member); $meetup->users()->attach($creator);
Livewire::test('meetups.edit', ['meetup' => $meetup]) Livewire::test('meetups.edit', ['meetup' => $meetup])
->set('name', 'Updated Name') ->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 () { it('rejects update when name collides with another existing Meetup', function () {
$member = actingAsUser(); $creator = actingAsUser();
$meetup = Meetup::factory()->create([ $meetup = Meetup::factory()->create([
'city_id' => $this->city->id, 'city_id' => $this->city->id,
'name' => 'Original Name', '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]); Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]);
Livewire::test('meetups.edit', ['meetup' => $meetup]) 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 () { it('allows update when name is unchanged (Rule::unique ignores own id)', function () {
$member = actingAsUser(); $creator = actingAsUser();
$meetup = Meetup::factory()->create([ $meetup = Meetup::factory()->create([
'city_id' => $this->city->id, 'city_id' => $this->city->id,
'name' => 'Original Name', 'name' => 'Original Name',
'created_by' => $creator->id,
]); ]);
$meetup->users()->attach($member); $meetup->users()->attach($creator);
Livewire::test('meetups.edit', ['meetup' => $meetup]) Livewire::test('meetups.edit', ['meetup' => $meetup])
->set('name', 'Original Name') ->set('name', 'Original Name')
@@ -58,11 +62,12 @@ it('allows update when name is unchanged (Rule::unique ignores own id)', functio
->assertHasNoErrors(); ->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(); actingAsUser();
$meetup = Meetup::factory()->create([ $meetup = Meetup::factory()->create([
'city_id' => $this->city->id, 'city_id' => $this->city->id,
'name' => 'Original Name', 'name' => 'Original Name',
'created_by' => User::factory()->create()->id,
]); ]);
Livewire::test('meetups.edit', ['meetup' => $meetup]) Livewire::test('meetups.edit', ['meetup' => $meetup])