From 119deb4f5c1c89dcbca164de14f4129fa3980d1a Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:10:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20`addToMine`=20functionality?= =?UTF-8?q?=20to=20Meetups=20API=20for=20adding=20meetups=20to=20a=20user'?= =?UTF-8?q?s=20"My=20Meetups"=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔒 Introduce `addToMine` policy for authenticated users to add existing meetups. - ✏️ Add `addToMine` method in `MeetupController` with idempotent handling. - ✨ Include `addMember` utility in `Meetup` model for managing pivot relationships. - 🛠️ Refactor `AddMeetupToMineTool` to use `addMember` for consistency. - 🧪 Add feature tests for `addToMine`, covering idempotency, permissions, and unknown slugs. - 🌐 Register `addToMine` route in API and link it to `MeetupController`. --- app/Http/Controllers/Api/MeetupController.php | 21 ++++++++++ app/Mcp/Tools/Meetup/AddMeetupToMineTool.php | 12 ++---- app/Models/Meetup.php | 17 ++++++++ app/Policies/MeetupPolicy.php | 11 +++++ routes/api.php | 1 + tests/Feature/Api/MeetupWriteApiTest.php | 41 +++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/MeetupController.php b/app/Http/Controllers/Api/MeetupController.php index 17cada5..29039dc 100644 --- a/app/Http/Controllers/Api/MeetupController.php +++ b/app/Http/Controllers/Api/MeetupController.php @@ -122,6 +122,27 @@ class MeetupController extends Controller return MeetupResource::collection($meetups); } + /** + * Bestehendes Meetup zu „Meine Meetups" hinzufügen + * + * Fügt ein bereits existierendes Meetup zur „Meine Meetups"-Liste des authentifizierten + * Nutzers hinzu (meetup_user-Pivot als Mitglied, is_leader=false). Idempotent: ein bereits + * hinzugefügtes Meetup bleibt unverändert. Die Stammdaten bleiben dem Ersteller vorbehalten. + */ + #[Response(status: 401, description: 'Nicht authentifiziert.')] + public function addToMine(Request $request, Meetup $meetup): JsonResponse + { + Gate::authorize('addToMine', $meetup); + + $wasAdded = $meetup->addMember($request->user()); + + return MeetupResource::make($meetup) + ->response() + ->setStatusCode($wasAdded + ? \Symfony\Component\HttpFoundation\Response::HTTP_CREATED + : \Symfony\Component\HttpFoundation\Response::HTTP_OK); + } + /** * Eigenes Meetup anzeigen * diff --git a/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php b/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php index c00fcf7..868873f 100644 --- a/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php +++ b/app/Mcp/Tools/Meetup/AddMeetupToMineTool.php @@ -39,17 +39,13 @@ class AddMeetupToMineTool extends Tool } } - $alreadyMember = $meetup->users()->whereKey($user->getAuthIdentifier())->exists(); - - $meetup->users()->syncWithoutDetaching([ - $user->getAuthIdentifier() => ['is_leader' => false], - ]); + $wasAdded = $meetup->addMember($user); 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.', + 'message' => $wasAdded + ? '„'.$meetup->name.'" wurde zu deinen Meetups hinzugefügt.' + : '„'.$meetup->name.'" war bereits Teil deiner Meetups.', ]); } diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index 321388a..8d61f01 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -185,6 +185,23 @@ class Meetup extends Model implements HasMedia return $this->users()->whereKey($user->id)->exists(); } + /** + * Den Nutzer als Mitglied (nicht Leader) zu „Meine Meetups" hinzufügen. + * Idempotent: ein bereits hinzugefügter Nutzer bleibt unverändert. Gibt + * true zurück, wenn neu hinzugefügt (false = war bereits Mitglied). + * Geteilt von REST-Controller (addToMine) und MCP-Tool (AddMeetupToMineTool). + */ + public function addMember(User $user): bool + { + $wasMember = $this->hasMember($user); + + $this->users()->syncWithoutDetaching([ + $user->getKey() => ['is_leader' => false], + ]); + + return ! $wasMember; + } + /** * RateLimiter-Key für Meetup-Stammdaten-Updates über das Portal-Frontend. */ diff --git a/app/Policies/MeetupPolicy.php b/app/Policies/MeetupPolicy.php index 5a2948a..e793eb2 100644 --- a/app/Policies/MeetupPolicy.php +++ b/app/Policies/MeetupPolicy.php @@ -36,6 +36,17 @@ class MeetupPolicy return true; } + /** + * Ein bestehendes Meetup zu „Meine Meetups" hinzufügen (meetup_user-Pivot als + * Mitglied, nicht als Leader). Jeder authentifizierte Nutzer darf das — die + * Stammdaten bleiben dem Ersteller vorbehalten (siehe update()). Spiegelt die + * offene Semantik des AddMeetupToMineTool (MCP). + */ + public function addToMine(User $user, Meetup $meetup): bool + { + return true; + } + public function update(User $user, Meetup $meetup): bool { return $this->owns($user, $meetup); diff --git a/routes/api.php b/routes/api.php index d9821f4..a1fd208 100644 --- a/routes/api.php +++ b/routes/api.php @@ -73,6 +73,7 @@ Route::middleware('auth:sanctum') Route::post('meetup', [MeetupController::class, 'store'])->name('meetup.store'); Route::patch('meetup/{meetup}', [MeetupController::class, 'update'])->name('meetup.update'); Route::get('my-meetups', [MeetupController::class, 'mine'])->name('meetup.mine'); + Route::post('my-meetups/{meetup:slug}', [MeetupController::class, 'addToMine'])->name('meetup.mine.add'); Route::get('my-meetups/{meetup}', [MeetupController::class, 'mineShow'])->name('meetup.mine.show'); Route::post('meetup-events', [MeetupEventController::class, 'store'])->name('meetup-events.store'); diff --git a/tests/Feature/Api/MeetupWriteApiTest.php b/tests/Feature/Api/MeetupWriteApiTest.php index 85ca80e..4d24b08 100644 --- a/tests/Feature/Api/MeetupWriteApiTest.php +++ b/tests/Feature/Api/MeetupWriteApiTest.php @@ -112,3 +112,44 @@ it('forbids viewing a meetup the user has not selected in mine show', function ( $this->getJson('/api/my-meetups/'.$meetup->id)->assertForbidden(); }); + +it('rejects a guest adding a meetup to mine', function () { + $meetup = Meetup::factory()->create(); + + $this->postJson('/api/my-meetups/'.$meetup->slug)->assertUnauthorized(); +}); + +it('lets an authenticated user add an existing foreign meetup to mine by slug', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs($user = User::factory()->create()); + + $this->postJson('/api/my-meetups/'.$meetup->slug) + ->assertCreated() + ->assertJsonPath('data.id', $meetup->id); + + $this->assertDatabaseHas('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $user->id, + 'is_leader' => false, + ]); +}); + +it('is idempotent and returns 200 when the meetup is already mine', function () { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(); + $user->meetups()->attach($meetup, ['is_leader' => false]); + + $this->postJson('/api/my-meetups/'.$meetup->slug) + ->assertOk() + ->assertJsonPath('data.id', $meetup->id); + + expect($user->meetups()->whereKey($meetup->id)->count())->toBe(1); +}); + +it('returns 404 for an unknown meetup slug', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/my-meetups/does-not-exist-9999')->assertNotFound(); +});