mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-23 18:30:23 +00:00
✨ Add addToMine functionality to Meetups API for adding meetups to a user's "My Meetups" list
- 🔒 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`.
This commit is contained in:
@@ -122,6 +122,27 @@ class MeetupController extends Controller
|
|||||||
return MeetupResource::collection($meetups);
|
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
|
* Eigenes Meetup anzeigen
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -39,17 +39,13 @@ class AddMeetupToMineTool extends Tool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$alreadyMember = $meetup->users()->whereKey($user->getAuthIdentifier())->exists();
|
$wasAdded = $meetup->addMember($user);
|
||||||
|
|
||||||
$meetup->users()->syncWithoutDetaching([
|
|
||||||
$user->getAuthIdentifier() => ['is_leader' => false],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Response::json([
|
return Response::json([
|
||||||
'meetup' => MeetupResource::make($meetup)->resolve(),
|
'meetup' => MeetupResource::make($meetup)->resolve(),
|
||||||
'message' => $alreadyMember
|
'message' => $wasAdded
|
||||||
? '„'.$meetup->name.'" war bereits Teil deiner Meetups.'
|
? '„'.$meetup->name.'" wurde zu deinen Meetups hinzugefügt.'
|
||||||
: '„'.$meetup->name.'" wurde zu deinen Meetups hinzugefügt.',
|
: '„'.$meetup->name.'" war bereits Teil deiner Meetups.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,23 @@ class Meetup extends Model implements HasMedia
|
|||||||
return $this->users()->whereKey($user->id)->exists();
|
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.
|
* RateLimiter-Key für Meetup-Stammdaten-Updates über das Portal-Frontend.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ class MeetupPolicy
|
|||||||
return true;
|
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
|
public function update(User $user, Meetup $meetup): bool
|
||||||
{
|
{
|
||||||
return $this->owns($user, $meetup);
|
return $this->owns($user, $meetup);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ Route::middleware('auth:sanctum')
|
|||||||
Route::post('meetup', [MeetupController::class, 'store'])->name('meetup.store');
|
Route::post('meetup', [MeetupController::class, 'store'])->name('meetup.store');
|
||||||
Route::patch('meetup/{meetup}', [MeetupController::class, 'update'])->name('meetup.update');
|
Route::patch('meetup/{meetup}', [MeetupController::class, 'update'])->name('meetup.update');
|
||||||
Route::get('my-meetups', [MeetupController::class, 'mine'])->name('meetup.mine');
|
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::get('my-meetups/{meetup}', [MeetupController::class, 'mineShow'])->name('meetup.mine.show');
|
||||||
|
|
||||||
Route::post('meetup-events', [MeetupEventController::class, 'store'])->name('meetup-events.store');
|
Route::post('meetup-events', [MeetupEventController::class, 'store'])->name('meetup-events.store');
|
||||||
|
|||||||
@@ -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();
|
$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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user