mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-20 05:30:30 +00:00
✨ Add RSVP functionality for Meetup Events
- 🏷️ Introduce `RsvpStatus` enum for managing attendance states (`attending`, `maybe`, `none`). - ✏️ Add `MeetupEventController` methods for RSVP actions (`rsvpStatus`, `rsvp`) and payload handling. - ✨ Implement RSVP helpers in `MeetupEvent` model for user-specific attendance management. - 🌐 Register RSVP routes for showing and updating attendance in the API. - 🧪 Add feature tests for RSVP actions, covering validation, idempotency, and correct list handling.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSVP-Status eines Nutzers für einen Meetup-Termin. `None` bildet den Zustand
|
||||||
|
* ab, dass der Nutzer in keiner der beiden Teilnehmer-Listen steht (= abgesagt
|
||||||
|
* bzw. nie zugesagt) und dient zugleich als Eingabewert zum Austragen.
|
||||||
|
*/
|
||||||
|
enum RsvpStatus: string
|
||||||
|
{
|
||||||
|
case Attending = 'attending';
|
||||||
|
case Maybe = 'maybe';
|
||||||
|
case None = 'none';
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Actions\MeetupEvents\CreateMeetupEventSeries;
|
use App\Actions\MeetupEvents\CreateMeetupEventSeries;
|
||||||
|
use App\Enums\RsvpStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\RsvpMeetupEventRequest;
|
||||||
use App\Http\Requests\Api\StoreMeetupEventRequest;
|
use App\Http\Requests\Api\StoreMeetupEventRequest;
|
||||||
use App\Http\Requests\Api\UpdateMeetupEventRequest;
|
use App\Http\Requests\Api\UpdateMeetupEventRequest;
|
||||||
use App\Http\Resources\MeetupEventResource;
|
use App\Http\Resources\MeetupEventResource;
|
||||||
use App\Models\MeetupEvent;
|
use App\Models\MeetupEvent;
|
||||||
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Carbon\Exceptions\InvalidFormatException;
|
use Carbon\Exceptions\InvalidFormatException;
|
||||||
use Dedoc\Scramble\Attributes\Group;
|
use Dedoc\Scramble\Attributes\Group;
|
||||||
@@ -158,4 +161,49 @@ class MeetupEventController extends Controller
|
|||||||
|
|
||||||
return MeetupEventResource::make($meetupEvent);
|
return MeetupEventResource::make($meetupEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSVP-Status eines Termins anzeigen
|
||||||
|
*
|
||||||
|
* Liefert den eigenen Teilnahme-Status des authentifizierten Nutzers für
|
||||||
|
* diesen Termin sowie die aktuellen Zähler der Zu- und Vielleicht-Sagen.
|
||||||
|
*/
|
||||||
|
public function rsvpStatus(Request $request, MeetupEvent $meetupEvent): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($this->rsvpPayload($meetupEvent, $request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Für einen Termin zu- oder absagen
|
||||||
|
*
|
||||||
|
* Trägt den authentifizierten Nutzer als Teilnehmer („attending"),
|
||||||
|
* Vielleicht-Teilnehmer („maybe") oder gar nicht („none", = absagen) ein.
|
||||||
|
* Der Anzeigename wird automatisch aus dem Profil übernommen. Idempotent:
|
||||||
|
* derselbe Status mehrfach gesetzt verändert nichts.
|
||||||
|
*/
|
||||||
|
#[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')]
|
||||||
|
#[ResponseAttribute(status: 422, description: 'Validierungsfehler (unbekannter Status).')]
|
||||||
|
public function rsvp(RsvpMeetupEventRequest $request, MeetupEvent $meetupEvent): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$status = RsvpStatus::from($request->validated('status'));
|
||||||
|
|
||||||
|
$meetupEvent->setRsvpFor($user, $status, (string) $user->name);
|
||||||
|
|
||||||
|
return response()->json($this->rsvpPayload($meetupEvent->fresh(), $user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche RSVP-Antwort: eigener Status + aktuelle Zähler.
|
||||||
|
*
|
||||||
|
* @return array{status: string, attendees: int, might_attendees: int}
|
||||||
|
*/
|
||||||
|
private function rsvpPayload(MeetupEvent $meetupEvent, User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $meetupEvent->rsvpStatusFor($user)->value,
|
||||||
|
'attendees' => count($meetupEvent->attendees ?? []),
|
||||||
|
'might_attendees' => count($meetupEvent->might_attendees ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
|
use App\Enums\RsvpStatus;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class RsvpMeetupEventRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Jeder authentifizierte Nutzer darf für einen Termin zu-/absagen.
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['required', Rule::enum(RsvpStatus::class)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\RecurrenceType;
|
use App\Enums\RecurrenceType;
|
||||||
|
use App\Enums\RsvpStatus;
|
||||||
use App\Observers\MeetupEventObserver;
|
use App\Observers\MeetupEventObserver;
|
||||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
#[ObservedBy([MeetupEventObserver::class])]
|
#[ObservedBy([MeetupEventObserver::class])]
|
||||||
class MeetupEvent extends Model
|
class MeetupEvent extends Model
|
||||||
@@ -62,4 +64,76 @@ class MeetupEvent extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Meetup::class);
|
return $this->belongsTo(Meetup::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eindeutige Kennung eines angemeldeten Nutzers in den Teilnehmer-Listen.
|
||||||
|
* Einträge werden als `id_<userId>|<name>` abgelegt; der angehängte Pipe
|
||||||
|
* grenzt z. B. `id_5` sauber von `id_50` ab.
|
||||||
|
*/
|
||||||
|
public static function rsvpIdentifierFor(User $user): string
|
||||||
|
{
|
||||||
|
return 'id_'.$user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix, mit dem ein Eintrag des Nutzers in den Listen beginnt — inklusive
|
||||||
|
* Pipe, damit `id_5` nicht auf `id_50` matcht.
|
||||||
|
*/
|
||||||
|
private static function rsvpPrefixFor(User $user): string
|
||||||
|
{
|
||||||
|
return self::rsvpIdentifierFor($user).'|';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktueller RSVP-Status des Nutzers für diesen Termin.
|
||||||
|
*/
|
||||||
|
public function rsvpStatusFor(User $user): RsvpStatus
|
||||||
|
{
|
||||||
|
$prefix = self::rsvpPrefixFor($user);
|
||||||
|
|
||||||
|
if (collect($this->attendees ?? [])->contains(fn ($entry): bool => str($entry)->startsWith($prefix))) {
|
||||||
|
return RsvpStatus::Attending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect($this->might_attendees ?? [])->contains(fn ($entry): bool => str($entry)->startsWith($prefix))) {
|
||||||
|
return RsvpStatus::Maybe;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RsvpStatus::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den RSVP-Status des Nutzers: entfernt ihn zunächst aus beiden Listen
|
||||||
|
* und trägt ihn anschließend in die gewählte Liste ein. `None` sagt nur ab
|
||||||
|
* (kein erneutes Eintragen). Persistiert die Änderung.
|
||||||
|
*/
|
||||||
|
public function setRsvpFor(User $user, RsvpStatus $status, string $name): void
|
||||||
|
{
|
||||||
|
$prefix = self::rsvpPrefixFor($user);
|
||||||
|
|
||||||
|
$attendees = $this->withoutEntry($this->attendees, $prefix);
|
||||||
|
$mightAttendees = $this->withoutEntry($this->might_attendees, $prefix);
|
||||||
|
|
||||||
|
$entry = $prefix.$name;
|
||||||
|
|
||||||
|
match ($status) {
|
||||||
|
RsvpStatus::Attending => $attendees->push($entry),
|
||||||
|
RsvpStatus::Maybe => $mightAttendees->push($entry),
|
||||||
|
RsvpStatus::None => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'attendees' => $attendees->values()->all(),
|
||||||
|
'might_attendees' => $mightAttendees->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string>|null $list
|
||||||
|
* @return Collection<int, string>
|
||||||
|
*/
|
||||||
|
private function withoutEntry(?array $list, string $prefix): Collection
|
||||||
|
{
|
||||||
|
return collect($list ?? [])->reject(fn ($entry): bool => str($entry)->startsWith($prefix));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ Route::middleware('auth:sanctum')
|
|||||||
Route::patch('meetup-events/{meetupEvent}', [MeetupEventController::class, 'update'])->name('meetup-events.update');
|
Route::patch('meetup-events/{meetupEvent}', [MeetupEventController::class, 'update'])->name('meetup-events.update');
|
||||||
Route::get('my-meetup-events', [MeetupEventController::class, 'mine'])->name('meetup-events.mine');
|
Route::get('my-meetup-events', [MeetupEventController::class, 'mine'])->name('meetup-events.mine');
|
||||||
Route::get('my-meetup-events/{meetupEvent}', [MeetupEventController::class, 'mineShow'])->name('meetup-events.mine.show');
|
Route::get('my-meetup-events/{meetupEvent}', [MeetupEventController::class, 'mineShow'])->name('meetup-events.mine.show');
|
||||||
|
Route::get('meetup-events/{meetupEvent}/rsvp', [MeetupEventController::class, 'rsvpStatus'])->name('meetup-events.rsvp.show');
|
||||||
|
Route::post('meetup-events/{meetupEvent}/rsvp', [MeetupEventController::class, 'rsvp'])->name('meetup-events.rsvp');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback'])
|
Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback'])
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\MeetupEvent;
|
||||||
|
use App\Models\User;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
it('rejects a guest', function () {
|
||||||
|
$event = MeetupEvent::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'attending'])
|
||||||
|
->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an authenticated user attend', function () {
|
||||||
|
Sanctum::actingAs($user = User::factory()->create(['name' => 'Satoshi']));
|
||||||
|
$event = MeetupEvent::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'attending'])
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJson(['status' => 'attending', 'attendees' => 1, 'might_attendees' => 0]);
|
||||||
|
|
||||||
|
expect($event->fresh()->attendees)->toBe(["id_{$user->id}|Satoshi"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the user between lists without duplicating', function () {
|
||||||
|
Sanctum::actingAs($user = User::factory()->create(['name' => 'Satoshi']));
|
||||||
|
$event = MeetupEvent::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'attending']);
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'maybe'])
|
||||||
|
->assertJson(['status' => 'maybe', 'attendees' => 0, 'might_attendees' => 1]);
|
||||||
|
|
||||||
|
$event->refresh();
|
||||||
|
expect($event->attendees)->toBe([])
|
||||||
|
->and($event->might_attendees)->toBe(["id_{$user->id}|Satoshi"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withdraws the user with status none', function () {
|
||||||
|
Sanctum::actingAs($user = User::factory()->create(['name' => 'Satoshi']));
|
||||||
|
$event = MeetupEvent::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'attending']);
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'none'])
|
||||||
|
->assertJson(['status' => 'none', 'attendees' => 0, 'might_attendees' => 0]);
|
||||||
|
|
||||||
|
expect($event->fresh()->attendees)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps other attendees untouched', function () {
|
||||||
|
$event = MeetupEvent::factory()->create([
|
||||||
|
'attendees' => ['id_999|Hal', 'id_50|Nick'],
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($user = User::factory()->create(['name' => 'Satoshi']));
|
||||||
|
|
||||||
|
// id_5 darf id_50 nicht versehentlich treffen (Prefix-Abgrenzung per Pipe).
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'attending'])
|
||||||
|
->assertJson(['attendees' => 3]);
|
||||||
|
|
||||||
|
expect($event->fresh()->attendees)
|
||||||
|
->toContain('id_999|Hal')
|
||||||
|
->toContain('id_50|Nick')
|
||||||
|
->toContain("id_{$user->id}|Satoshi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the current status and counts', function () {
|
||||||
|
Sanctum::actingAs($user = User::factory()->create(['name' => 'Satoshi']));
|
||||||
|
$event = MeetupEvent::factory()->create([
|
||||||
|
'attendees' => ['id_999|Hal'],
|
||||||
|
'might_attendees' => ["id_{$user->id}|Satoshi"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson("/api/meetup-events/{$event->id}/rsvp")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertJson(['status' => 'maybe', 'attendees' => 1, 'might_attendees' => 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the status value', function () {
|
||||||
|
Sanctum::actingAs(User::factory()->create());
|
||||||
|
$event = MeetupEvent::factory()->create();
|
||||||
|
|
||||||
|
$this->postJson("/api/meetup-events/{$event->id}/rsvp", ['status' => 'bogus'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['status']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user