diff --git a/app/Enums/RsvpStatus.php b/app/Enums/RsvpStatus.php new file mode 100644 index 0000000..b10e3f3 --- /dev/null +++ b/app/Enums/RsvpStatus.php @@ -0,0 +1,15 @@ +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 ?? []), + ]; + } } diff --git a/app/Http/Requests/Api/RsvpMeetupEventRequest.php b/app/Http/Requests/Api/RsvpMeetupEventRequest.php new file mode 100644 index 0000000..5becf2e --- /dev/null +++ b/app/Http/Requests/Api/RsvpMeetupEventRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'status' => ['required', Rule::enum(RsvpStatus::class)], + ]; + } +} diff --git a/app/Models/MeetupEvent.php b/app/Models/MeetupEvent.php index ca1ee61..1cff8c6 100644 --- a/app/Models/MeetupEvent.php +++ b/app/Models/MeetupEvent.php @@ -3,11 +3,13 @@ namespace App\Models; use App\Enums\RecurrenceType; +use App\Enums\RsvpStatus; use App\Observers\MeetupEventObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Collection; #[ObservedBy([MeetupEventObserver::class])] class MeetupEvent extends Model @@ -62,4 +64,76 @@ class MeetupEvent extends Model { return $this->belongsTo(Meetup::class); } + + /** + * Eindeutige Kennung eines angemeldeten Nutzers in den Teilnehmer-Listen. + * Einträge werden als `id_|` 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|null $list + * @return Collection + */ + private function withoutEntry(?array $list, string $prefix): Collection + { + return collect($list ?? [])->reject(fn ($entry): bool => str($entry)->startsWith($prefix)); + } } diff --git a/routes/api.php b/routes/api.php index ee8a53a..590b9d6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -85,6 +85,8 @@ Route::middleware('auth:sanctum') 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/{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']) diff --git a/tests/Feature/Api/MeetupEventRsvpApiTest.php b/tests/Feature/Api/MeetupEventRsvpApiTest.php new file mode 100644 index 0000000..ca5dfbc --- /dev/null +++ b/tests/Feature/Api/MeetupEventRsvpApiTest.php @@ -0,0 +1,84 @@ +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']); +});