diff --git a/API_PARITAET_PLAN.md b/API_PARITAET_PLAN.md new file mode 100644 index 0000000..f3b012c --- /dev/null +++ b/API_PARITAET_PLAN.md @@ -0,0 +1,154 @@ +# Portal-API-Paritäts-Plan — „Write-Parität App ↔ Web" + +> **Mission:** Die Portal-**REST-API** so vervollständigen, dass die Mobile-App +> jede **EDIT/CREATE**-Funktion ausführen kann, die das Portal-**Web** (Livewire/Volt) +> bereits beherrscht. Ziel ist Funktions-Parität: Was ein angemeldeter Nutzer im Web +> anlegen/bearbeiten kann, muss er auch über die API (= in der App) können. Zusätzlich +> wird die **Scramble-API-Doku** auf den vollständigen, aktuellen Stand gebracht. +> +> **Single Source of Truth.** Diese Datei wird abgehakt. Bei Session-Start lesen, erste +> offene `[ ]`-Checkbox finden, dort weitermachen, Erledigtes auf `[x]` setzen. Größere +> Entscheidungen ins **Entscheidungs-Log** (unten). + +--- + +## Ausgangslage (Stand 2026-06-15, am Quellcode verifiziert) + +- **Portal-Stack:** Laravel 12 · Livewire 4 (Volt-SFC) · Flux UI v2 · Sanctum v4 · Horizon · Pest 4. Doku via **Scramble** (Dedoc, OpenAPI 3 → `api.json`, UI unter `/docs/api`), generiert aus FormRequests + API-Resources + PHPDoc/Attributen (`config/scramble.php`). +- **Web kann bereits ALLES:** Für Meetups, Meetup-Termine, Venues, Cities, Lecturers, Courses, Course-Events **und** „Meetup zu meinen hinzufügen" existieren Create-/Edit-Volt-Komponenten unter `resources/views/livewire/**` + Web-Routen (`routes/web.php:113-151`). **Kein** Admin-Panel (Filament/Nova) — normale Nutzer verwalten ihre eigenen Inhalte. +- **Die API hinkt hinterher** — die App nutzt sie und stößt an diese Lücken: + 1. **Media-Upload fehlt komplett in der API.** Models haben Spatie-Collections (`Meetup.logo`, `Lecturer.avatar`+`images`, `Course.logo`+`images`, `Venue.images`), aber es gibt **keinen** multipart-Endpunkt. Das Web lädt direkt via Livewire `flux:file-upload` → `addMedia(...)->toMediaCollection(...)` hoch. + 2. **Course & CourseEvent sind API-seitig inkonsistent:** **keine** FormRequest-Klassen (inline `$request->validate()`), **keine** `CoursePolicy`/`CourseEventPolicy` (Autorisierung inline via `abort_unless`), **keine** API-Resource (Controller geben `->fresh()` = rohes Model zurück). Alle anderen Entitäten nutzen StoreXRequest/UpdateXRequest + Policy + XResource. + 3. **Recurrence ohne Expansion in der API:** Das Web expandiert eine Serie beim Speichern in **einzelne** `MeetupEvent`-Records (`meetups/create-edit-events.blade.php` `createEventSeries()`). Die `recurrence_*`-Spalten werden gespeichert, aber **nicht** zur Anzeige gelesen und **nicht** vom Observer expandiert. Die API akzeptiert die `recurrence_*`-Felder, erzeugt aber **keine** Serie. + 4. **Doku unvollständig:** zuletzt ergänzte Endpunkte (`my-meetups`, `addToMine`, `my-lecturers`/`my-venues`/`my-cities`/`my-meetup-events`, `mobile/token`) sind ohne kuratierte Beschreibung; Course/CourseEvent liefern mangels Resource kein sauberes Response-Schema an Scramble. + +### Bewusste Nicht-Ziele (keine Paritätslücke) + +- **Kurs-Kategorien:** Die `Course::categories()`-Relation existiert, wird aber **auch im Web nicht gepflegt** (nur `DatabaseSeeder` vergibt zufällig). Kein Web-Feature → kein Paritätsziel. → **Backlog** (siehe Offene Fragen). +- **Venue-Geo/Typ:** Venues haben portalseitig keine Geo-/Typ-Spalten (Geo hängt an `cities.latitude/longitude`); das Web kennt das ebenfalls nicht. Kein Paritätsziel. +- **DELETE / „absagen/inaktiv":** Das Web hat **keine** Lösch-/Absage-Funktion für normale Nutzer (kein `cancelled`-Feld, keine DELETE-Route). Da der Auftrag „EDIT/CREATE" lautet, ist DELETE **nicht** im Hauptscope → optionaler Punkt (siehe Offene Fragen). + +### Leitprinzipien + +1. **Web ist die Referenz.** Validierungsregeln, Collections und Autorisierungs-Semantik 1:1 aus den Volt-Komponenten spiegeln — keine Neuerfindung. +2. **Konsistenz vor Sonderlocken.** Course/CourseEvent auf dasselbe FormRequest+Policy+Resource-Muster heben wie Meetup/Venue/City/Lecturer. +3. **Doku-getrieben.** Saubere FormRequests + Resources = saubere Scramble-Schemas. Doku ist Nebenprodukt korrekter Klassen, nicht handgepflegtes JSON. +4. **DRY zwischen Web & API.** Wo Web und API dieselbe Fachlogik brauchen (Recurrence-Expansion, Media-Validierung), eine gemeinsame Action/FormRequest extrahieren, statt zu duplizieren. +5. **Keine Breaking Changes.** Bestehende Request-Felder/Response-Shapes bleiben kompatibel (additiv erweitern). +6. **Jede Änderung getestet** (Pest-Feature-Tests gegen die API-Routen), `vendor/bin/pint` vor Abschluss. + +--- + +## Phase P0 — Konsistenz-Fundament: Course & CourseEvent angleichen 🏗️ + +> Ohne FormRequests + Resources kann Scramble keine sauberen Schemas erzeugen, und die Upload-/Serien-Arbeit baut darauf auf. Zuerst die Schiene legen. + +- [x] **P0.1** `StoreCourseRequest` + `UpdateCourseRequest` (`app/Http/Requests/Api/`) anlegen — Felder `name` (req, max:255), `lecturer_id` (req, exists:lecturers,id), `description` (nullable). `authorize()` über `CoursePolicy`. Update mit `sometimes`. +- [x] **P0.2** `StoreCourseEventRequest` + `UpdateCourseEventRequest` — Felder `course_id` (req, exists), `venue_id` (req, exists), `from` (req, date), `to` (req, date, after_or_equal:from), `link` (req, url, max:255). +- [x] **P0.3** `CoursePolicy` + `CourseEventPolicy` anlegen (im selben Muster wie `MeetupPolicy`/`VenuePolicy`, `ChecksCreatorOwnership`-Trait nutzen): + - `CoursePolicy::create` = `is_lecturer` (spiegelt das bisherige `abort_unless($user->is_lecturer)`), `update` = owner||super-admin. + - `CourseEventPolicy::create` = `is_lecturer`, `update` = owner||super-admin. + - In `AuthServiceProvider`/Policy-Auto-Discovery registrieren. +- [x] **P0.4** `CourseResource` + `CourseEventResource` anlegen (Feldwahl an `CourseController::show`/`index` orientieren; Logo-/Venue-/Lecturer-Beziehungen wie in den Lese-Endpunkten). Damit liefern store/update strukturierte, dokumentierbare Responses statt `->fresh()`. +- [x] **P0.5** `CourseController` + `CourseEventController` refactoren: Inline-`validate()` → FormRequest, Inline-`abort_unless` → `$this->authorize(...)`/Policy, `->fresh()` → Resource. Verhalten unverändert (gleiche Felder, gleiche 403/422-Semantik). +- [x] **P0.6** Tests: `tests/Feature/Api/CourseWriteTest.php` + `CourseEventWriteTest.php` (Create/Update, 401 Gast, 403 Nicht-Lecturer/Fremd-Edit, 422 Pflichtfelder, Response-Shape). Bestehende Lese-Tests dürfen nicht brechen. + +**Akzeptanz:** Course/CourseEvent verhalten sich nach außen identisch wie zuvor, basieren intern aber auf FormRequest+Policy+Resource; Scramble zeigt für `POST/PATCH /courses` + `/course-events` korrekte Request-/Response-Schemas. + +--- + +## Phase P1 — Media-Upload-API (Logo / Avatar) 🖼️ + +> Die größte echte Paritätslücke. Das Web lädt via `flux:file-upload` direkt in Spatie-Collections; die API kann es gar nicht. + +- [x] **P1.1** Entscheidung umsetzen: **dedizierte Upload-Endpunkte** statt multipart-in-Store (siehe Log). Routen in der `auth:sanctum`-Gruppe: + - `POST /api/meetup/{meetup}/logo` → `MeetupController::uploadLogo` + - `POST /api/lecturers/{lecturer}/avatar` → `LecturerController::uploadAvatar` + - `POST /api/courses/{course}/logo` → `CourseController::uploadLogo` + - *(optional, falls App es braucht: `…/images` für Multi-Collections bei Lecturer/Course/Venue — erst bei Bedarf, siehe Offene Fragen.)* +- [x] **P1.2** Gemeinsame `UploadMediaRequest` (FormRequest) mit der **exakt aus dem Web gespiegelten** Validierung: `image|mimes:jpeg,png,webp,avif|max:5120|dimensions:max_width=4000,max_height=4000` (Feld `file`/`image`). `authorize()` = `update`-Policy der jeweiligen Entität (nur Ersteller/Admin). +- [x] **P1.3** Controller-Methoden spiegeln das Web-Muster: `clearMediaCollection(...)` (singleFile) → `addMedia($file->getRealPath())->usingName(...)->toMediaCollection('logo'|'avatar')`; Response = die jeweilige Resource (mit frischer Media-URL). +- [x] **P1.4** `MediaUrl`/Resource-Felder prüfen: stellen die Resources die Conversion-URLs (`preview`/`thumb`) + Original bereit, damit die App das neue Bild sofort anzeigt? +- [x] **P1.5** Tests: `tests/Feature/Api/MediaUploadTest.php` mit `UploadedFile::fake()->image(...)` — Erfolg (Collection befüllt, URL geändert), 401 Gast, 403 Fremd-Upload, 422 (kein Bild / zu groß / falscher MIME / zu große Dimension). + +**Akzeptanz:** Ein verbundener Nutzer kann per API ein Meetup-Logo, Referenten-Avatar und Kurs-Logo hoch-/ersetzen; Validierung deckungsgleich mit dem Web; Response liefert die neue Bild-URL. + +--- + +## Phase P2 — Recurrence-Serien-Parität (Meetup-Termine) 🔁 + +> Das Web erzeugt aus einer Wiederhol-Regel **echte Einzeltermine**. Die API muss dasselbe tun — ohne die Logik zu duplizieren. + +- [x] **P2.1** Die Expansionslogik aus `meetups/create-edit-events.blade.php` (`getPreviewDatesProperty()` + `createEventSeries()`) in eine wiederverwendbare **Action/Service** extrahieren, z. B. `app/Actions/MeetupEvents/ExpandRecurrenceSeries.php` (gibt die Liste der `start`-DateTimes zurück) + `CreateMeetupEventSeries` (persistiert die Einzeltermine). Carbon-basiert, UTC-sauber, harte Obergrenze (z. B. 100 Termine wie im Web). +- [x] **P2.2** Das **Web** auf die neue Action umstellen (Refactor ohne Verhaltensänderung — Preview + Speichern nutzen dieselbe Quelle). Sichert echte Parität + verhindert Drift. +- [x] **P2.3** `MeetupEventController::store` erweitern: bei gesetztem `recurrence_type` die Action aufrufen (Serie anlegen) statt eines Einzeltermins; Response = Liste der erzeugten Events (oder das erste + Anzahl). Ohne `recurrence_type` unverändert ein Einzeltermin. `StoreMeetupEventRequest` validiert die `recurrence_*`-Felder bereits — Regeln gegen die Action-Erwartungen abgleichen. +- [x] **P2.4** Klären/entscheiden: bleibt der `recurrence_*`-Metadaten-Block am ersten Event erhalten (für späteres iCal/rrule), oder werden nur Einzeltermine persistiert? (Web speichert aktuell Einzeltermine; siehe Offene Fragen.) +- [x] **P2.5** Tests: Serie über die API anlegen (wöchentlich/monatlich, `recurrence_end_date`) → erwartete Anzahl Einzeltermine; Obergrenze; Einzeltermin-Pfad unverändert; gemeinsame Action unit-getestet. + +**Akzeptanz:** Ein API-Aufruf mit Wiederhol-Regel erzeugt dieselben Einzeltermine wie der Web-Editor (gemeinsame Action, kein Duplikat-Code). + +--- + +## Phase P3 — Scramble-API-Doku vervollständigen 📖 + +> Alles Neue **und** das zuletzt Erstellte dokumentieren. + +- [x] **P3.1** Alle Write-Controller-Methoden mit kuratiertem PHPDoc + `#[Group(...)]` versehen (Titel + 1–2 Sätze Zweck, Auth-Hinweis), Muster wie `CourseController::show`. Betroffen: Meetup/MeetupEvent/Venue/City/Lecturer/Course/CourseEvent store+update, die neuen Upload-Endpunkte (P1), die Serie (P2). +- [x] **P3.2** Die **zuletzt erstellten** Endpunkte dokumentieren: `GET /my-meetups`, `POST /my-meetups/{slug}` (addToMine, idempotent), `GET /my-meetups/{meetup}`, `GET /my-lecturers(+/{})`, `GET /my-venues(+/{})`, `GET /my-cities(+/{})`, `GET /my-meetup-events(+/{})`, `GET /course-events`, `POST/DELETE /mobile/token`, Nostr/LNURL-Callbacks. +- [x] **P3.3** Fehler-Antworten dokumentieren (401/403/422) — wo Scramble sie nicht automatisch ableitet, via Attribut/PHPDoc ergänzen. +- [x] **P3.4** `GET /api/meetup` (öffentliche Index-Route, von der App-seitig als 401-Falle erkannt) prüfen: korrekt als **öffentlich/ohne Member-Kontext** dokumentieren bzw. ggf. deprecaten (die App nutzt `my-meetups`). +- [x] **P3.5** `api.json` regenerieren und `/docs/api` (Scalar) sichten — alle Write-Pfade sichtbar, mit Bearer-Markierung, Request-/Response-Schema, Beispielen. + +**Akzeptanz:** `/docs/api` zeigt sämtliche Schreib- und „my-*"-Endpunkte vollständig mit Beschreibung, Auth, Parametern und Schemas; `api.json` ist aktuell. + +--- + +## Phase P4 — Autorisierungs-Parität entscheiden 🔐 + +- [x] **P4.1** **`updateViaPortal` vs. `update`** (Offene Frage): Das Web erlaubt **Ersteller ODER Meetup-Mitglied** das Bearbeiten der Meetup-Stammdaten (`MeetupPolicy::updateViaPortal`), die API nur den **Ersteller** (`update`). Entscheiden: soll die API (= App) dieselbe gelockerte Regel bekommen (echte 1:1-Parität) oder bewusst strikt bleiben? Default-Empfehlung: **strikt** lassen + dokumentieren, bis ein echtes Rollen-/Freigabekonzept existiert. Falls Parität gewünscht: `MeetupController::update` auf `updateViaPortal` umstellen + Tests. +- [x] **P4.2** Ergebnis im Entscheidungs-Log + in der API-Doku festhalten. + +--- + +## Phase A — App nachziehen (Repo `einundzwanzig-mobile-app`) + +> Folge-Arbeit, sobald die Portal-Phasen stehen. Entsperrt die in `VERSION_1_2_0.md` als „portalseitig blockiert" geparkten Punkte. Detailplanung dort. + +- [ ] **A1** **Logo/Avatar-Upload in den App-Editoren** (NativePHP Camera/Photo-Picker → multipart an die neuen Endpunkte aus P1). Entsperrt `VERSION_1_2_0.md` **4.6** (Meetup-Logo) + **7.1** (Referenten-Avatar). Felder in `meetup-editor`/`lecturer-editor`/`course-editor` ergänzen, `PortalWriter` um Upload-Methoden erweitern. +- [ ] **A2** **Recurrence-UI im `event-editor`** (Wiederhol-Typ/Tag/Position/Ende) → Serie über den Endpunkt aus P2 anlegen. Entsperrt `VERSION_1_2_0.md` **5.4**. +- [ ] **A3** Optional, nur falls P-Backlog gezogen wird: Kurs-Kategorie-Auswahl (erst wenn das **Web** sie bekommt — sonst keine Parität). + +--- + +## Empfohlene Reihenfolge + +`P0 → P1 → P2 → P3 → P4 → A1 → A2` + +Begründung: P0 (Konsistenz-Fundament) ist Voraussetzung für saubere Doku und baut die Schiene für P1/P2. Media-Upload (P1) ist die größte Nutzer-spürbare Lücke. Recurrence (P2) braucht den Web-Refactor (DRY). Doku (P3) schließt den Portal-Teil ab, P4 ist eine kleine Policy-Entscheidung. Danach zieht die App (A) die neuen Fähigkeiten nach. + +--- + +## Offene Fragen / Klären + +- [x] **Upload-Design:** **Entschieden — dedizierte Endpunkte, zweistufig.** Die App legt Stammdaten per JSON an und lädt das Bild danach separat hoch (`POST …/{id}/logo|avatar`). Bild-Update unabhängig von Stammdaten. +- [ ] **Multi-Image-Collections** (`Lecturer.images`, `Course.images`, `Venue.images`): braucht die App das, oder reicht das Single-File-Logo/Avatar? (Nur bei Bedarf bauen — **noch offen, bewusst nicht gebaut**.) +- [x] **Recurrence-Persistenz (P2.4):** **Entschieden — nur Einzeltermine** (1:1 wie Web heute), keine `recurrence_*`-Metadaten am Master-Event. iCal/rrule bleibt Backlog. +- [x] **`updateViaPortal`-Parität (P4.1):** **Entschieden — API bleibt strikt** (nur Ersteller/Super-Admin). Die gelockerte Mitglieder-Regel bleibt Portal-/Livewire-exklusiv (`MeetupPolicy::updateViaPortal`), bis ein echtes Rollen-/Freigabekonzept existiert. +- [ ] **Kategorien (Backlog):** Soll das **Web** zuerst eine Kategorie-Verwaltung bekommen? Erst dann ergibt eine Kategorie-API für die App-Parität Sinn. Relation + `category_course`-Pivot + `Category`-Model existieren bereits. +- [ ] **DELETE/Absage (Backlog):** Sollen Nutzer eigene Termine/Inhalte löschen oder absagen können? Bräuchte Web **und** API + Policies (`delete`) + ggf. ein `cancelled`/`is_active`-Statusfeld. Aktuell weder Web noch API. + +--- + +## Entscheidungs-Log + +| Datum | Entscheidung | Begründung | +|------|--------------|-----------| +| 2026-06-15 | Plan-Ziel = **API-Schreib-Parität App ↔ Web**, Web bleibt unangetastet | Recherche ergab: das Web kann bereits alles (Volt-Editoren), die App hinkt nur hinterher, weil die REST-API Upload/Serien nicht exponiert und Course/CourseEvent inkonsistent sind. Parität wird durch API-Ausbau + App-Nachzug erreicht, nicht durch Web-Umbau | +| 2026-06-15 | **Kategorien & Venue-Geo aus dem Hauptscope** | Beide sind **auch im Web** kein Nutzer-Feature (Kategorie nur per Seeder, Venue ohne Geo-Spalten) → keine Paritätslücke. Kategorie wandert ins Backlog (Relation existiert), Venue-Geo bleibt als bewusst stadtgebunden | +| 2026-06-15 | **Course/CourseEvent zuerst auf FormRequest+Policy+Resource heben (P0)** | Sie sind die einzigen Entitäten mit Inline-Validate, ohne Policy und ohne Resource. Scramble braucht Requests/Resources für saubere Schemas; Upload/Serie bauen darauf auf — Konsistenz zuerst | +| 2026-06-15 | **Media-Upload als dedizierte Endpunkte** (`POST …/{id}/logo` etc.), nicht multipart-in-Store | Trennt Bild-Upload vom JSON-Stammdaten-Write (die App-Editoren senden JSON); vermeidet gemischte multipart/JSON-Requests; erlaubt Bild-Ersatz ohne Stammdaten-Update. Validierung 1:1 aus dem Web gespiegelt (`image|mimes:jpeg,png,webp,avif|max:5120|dimensions:…`) | +| 2026-06-15 | **Recurrence-Expansion als gemeinsame Action** (Web + API), nicht Server-Observer | Das Web expandiert bereits beim Speichern in Einzeltermine (`createEventSeries`); die `recurrence_*`-Spalten werden nicht zur Anzeige gelesen. Eine geteilte Action erreicht echte 1:1-Parität ohne Code-Duplikat und ohne das bestehende Anzeige-Modell (Einzeltermine) zu ändern | +| 2026-06-15 | **DELETE/Absage nicht im Hauptscope** | Auftrag lautet „EDIT/CREATE". Weder Web noch API bieten Löschen/Absagen für Nutzer; als Backlog-Frage dokumentiert statt halbfertig zu bauen | +| 2026-06-15 | **P0–P4 umgesetzt** (Portal-Teil abgeschlossen) | Course/CourseEvent auf FormRequest+Policy+Resource gehoben; Media-Upload-API (Meetup-Logo, Lecturer-Avatar, Course-Logo) via dedizierte Endpunkte + gemeinsame `UploadMediaRequest`; Recurrence-Expansion als geteilte Action `ExpandRecurrenceSeries` (Web + API, harte Obergrenze 100); Scramble-Doku verifiziert (`scramble:analyze` fehlerfrei). 130 Tests grün, Pint sauber | +| 2026-06-15 | **API-Antworten für Course/CourseEvent jetzt `data`-gewrappt** (Resource) | Konsistenz mit Meetup/Venue/Lecturer (alle `data`-gewrappt). Bedeutet eine Shape-Änderung ggü. dem alten rohen JSON — wird in Phase A (Mobile-App) nachgezogen. Bestehende Tests entsprechend aktualisiert | +| 2026-06-15 | **`recurrence_type` ohne `recurrence_end_date` ⇒ Einzeltermin** | Serie wird nur erzeugt, wenn beide Felder gesetzt sind (wie der Web-Serien-Modus mit Pflicht-Enddatum). Sonst Backward-Compatible: ein einzelnes Event inkl. der übergebenen `recurrence_*`-Spalten | diff --git a/app/Actions/MeetupEvents/CreateMeetupEventSeries.php b/app/Actions/MeetupEvents/CreateMeetupEventSeries.php new file mode 100644 index 0000000..1ff2edc --- /dev/null +++ b/app/Actions/MeetupEvents/CreateMeetupEventSeries.php @@ -0,0 +1,43 @@ + $data Validated StoreMeetupEventRequest payload. + * @return Collection + */ + public function handle(array $data): Collection + { + $dates = $this->expandRecurrenceSeries->handle( + Carbon::parse($data['start']), + Carbon::parse($data['recurrence_end_date']), + RecurrenceType::from($data['recurrence_type']), + $data['recurrence_day_of_week'] ?? null, + $data['recurrence_day_position'] ?? null, + ); + + return collect($dates)->map(fn (Carbon $start): MeetupEvent => MeetupEvent::create([ + 'meetup_id' => $data['meetup_id'], + 'start' => $start, + 'location' => $data['location'] ?? null, + 'description' => $data['description'] ?? null, + 'link' => $data['link'] ?? null, + 'attendees' => [], + 'might_attendees' => [], + ])); + } +} diff --git a/app/Actions/MeetupEvents/ExpandRecurrenceSeries.php b/app/Actions/MeetupEvents/ExpandRecurrenceSeries.php new file mode 100644 index 0000000..8412d25 --- /dev/null +++ b/app/Actions/MeetupEvents/ExpandRecurrenceSeries.php @@ -0,0 +1,166 @@ + + */ + public function handle( + CarbonInterface $start, + CarbonInterface $end, + RecurrenceType $type, + ?string $dayOfWeek = null, + ?string $dayPosition = null, + ): array { + $start = $start->copy(); + $end = $end->copy(); + + if ($dayOfWeek && $dayPosition) { + return $this->customRecurrence($start, $end, $dayOfWeek, $dayPosition); + } + + if ($type === RecurrenceType::Weekly && $dayOfWeek) { + $dayOfWeekNumber = self::dayOfWeekNumber($dayOfWeek); + + if ($dayOfWeekNumber !== null) { + $cursor = $start->copy(); + + while ($cursor->dayOfWeek !== $dayOfWeekNumber) { + $cursor->addDay(); + } + + return $this->collect($cursor, $end, fn (Carbon $date) => $date->addWeek()); + } + } + + return $this->collect( + $start, + $end, + fn (Carbon $date) => $type === RecurrenceType::Weekly ? $date->addWeek() : $date->addMonth(), + ); + } + + /** + * @param Closure(Carbon): mixed $advance + * @return array + */ + private function collect(CarbonInterface $cursor, CarbonInterface $end, Closure $advance): array + { + $dates = []; + $current = $cursor->copy(); + + while ($current->lessThanOrEqualTo($end) && count($dates) < self::MAX_OCCURRENCES) { + $dates[] = $current->copy(); + $advance($current); + } + + return $dates; + } + + /** + * @return array + */ + private function customRecurrence(CarbonInterface $start, CarbonInterface $end, string $dayOfWeek, string $dayPosition): array + { + $dates = []; + $cursor = $start->copy()->startOfMonth(); + + while ($cursor->lessThanOrEqualTo($end) && count($dates) < self::MAX_OCCURRENCES) { + $occurrence = $this->findOccurrence($cursor, $dayOfWeek, $dayPosition); + + if ($occurrence && $occurrence->lessThanOrEqualTo($end)) { + $occurrenceWithTime = $occurrence->copy()->setTimeFrom($start); + + if ($occurrenceWithTime->greaterThanOrEqualTo($start)) { + $dates[] = $occurrenceWithTime; + } + + $cursor = $cursor->copy()->addMonth(); + } else { + break; + } + } + + return $dates; + } + + private function findOccurrence(CarbonInterface $monthCursor, string $dayOfWeek, string $dayPosition): ?Carbon + { + $dayOfWeekNumber = self::dayOfWeekNumber($dayOfWeek); + $dayPositionNumber = self::dayPositionNumber($dayPosition); + + if ($dayOfWeekNumber === null || $dayPositionNumber === null) { + return $monthCursor->copy(); + } + + $date = $monthCursor->copy()->startOfMonth(); + + if ($dayPositionNumber === -1) { + return $date->lastOfMonth($dayOfWeekNumber) + ->setTime($monthCursor->hour, $monthCursor->minute, $monthCursor->second); + } + + $count = 0; + + while ($date->month === $monthCursor->month) { + if ($date->dayOfWeek === $dayOfWeekNumber) { + $count++; + + if ($count === $dayPositionNumber) { + return $date->copy() + ->setTime($monthCursor->hour, $monthCursor->minute, $monthCursor->second); + } + } + + $date->addDay(); + } + + return null; + } + + private static function dayOfWeekNumber(string $day): ?int + { + return match (strtolower($day)) { + 'monday', 'montag' => Carbon::MONDAY, + 'tuesday', 'dienstag' => Carbon::TUESDAY, + 'wednesday', 'mittwoch' => Carbon::WEDNESDAY, + 'thursday', 'donnerstag' => Carbon::THURSDAY, + 'friday', 'freitag' => Carbon::FRIDAY, + 'saturday', 'samstag' => Carbon::SATURDAY, + 'sunday', 'sonntag' => Carbon::SUNDAY, + default => null, + }; + } + + private static function dayPositionNumber(string $position): ?int + { + return match (strtolower($position)) { + 'first', 'erster' => 1, + 'second', 'zweiter' => 2, + 'third', 'dritter' => 3, + 'fourth', 'vierter' => 4, + 'last', 'letzter' => -1, + default => null, + }; + } +} diff --git a/app/Http/Controllers/Api/CourseController.php b/app/Http/Controllers/Api/CourseController.php index cd9272b..7658ae8 100644 --- a/app/Http/Controllers/Api/CourseController.php +++ b/app/Http/Controllers/Api/CourseController.php @@ -4,6 +4,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Api\Concerns\FiltersNumericIds; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreCourseRequest; +use App\Http\Requests\Api\UpdateCourseRequest; +use App\Http\Requests\Api\UploadMediaRequest; +use App\Http\Resources\CourseResource; use App\Models\Course; use App\Models\CourseEvent; use App\Models\Lecturer; @@ -100,21 +104,16 @@ class CourseController extends Controller * Kurs anlegen * * Erlaubt einem authentifizierten Referenten, einen Kurs programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch auf den angemeldeten Nutzer gesetzt. */ #[ResponseAttribute(status: 403, description: 'Nur Referenten (is_lecturer) dürfen Kurse anlegen.')] - public function store(Request $request): JsonResponse + public function store(StoreCourseRequest $request): JsonResponse { - abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + $course = Course::create($request->validated()); - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'lecturer_id' => ['required', 'exists:lecturers,id'], - 'description' => ['nullable', 'string'], - ]); - - $course = Course::create($validated); - - return response()->json($course->fresh(), Response::HTTP_CREATED); + return CourseResource::make($course->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } /** @@ -178,22 +177,29 @@ class CourseController extends Controller * Aktualisiert einen Kurs; nur für den Ersteller oder einen Super-Admin. */ #[ResponseAttribute(status: 403, description: 'Nur der Ersteller des Kurses oder ein Super-Admin darf ihn ändern.')] - public function update(Request $request, Course $course): JsonResponse + public function update(UpdateCourseRequest $request, Course $course): CourseResource { - abort_unless( - (int) $course->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), - Response::HTTP_FORBIDDEN - ); + $course->update($request->validated()); - $validated = $request->validate([ - 'name' => ['sometimes', 'required', 'string', 'max:255'], - 'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'], - 'description' => ['sometimes', 'nullable', 'string'], - ]); + return CourseResource::make($course->fresh()); + } - $course->update($validated); + /** + * Kurs-Logo hochladen + * + * Lädt ein Logo (multipart, Feld „file") in die singleFile-Collection „logo" und ersetzt + * dabei ein vorhandenes Logo. Nur für den Ersteller oder einen Super-Admin. Die Antwort + * enthält die frische Logo-URL. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Logo ersetzen.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler (kein Bild, falscher MIME-Typ, zu groß oder zu große Abmessungen).')] + public function uploadLogo(UploadMediaRequest $request, Course $course): CourseResource + { + $course->addMedia($request->file('file')->getRealPath()) + ->usingName($course->name) + ->toMediaCollection('logo'); - return response()->json($course->fresh()); + return CourseResource::make($course->fresh()); } #[ExcludeRouteFromDocs] diff --git a/app/Http/Controllers/Api/CourseEventController.php b/app/Http/Controllers/Api/CourseEventController.php index 2bae985..8bbe581 100644 --- a/app/Http/Controllers/Api/CourseEventController.php +++ b/app/Http/Controllers/Api/CourseEventController.php @@ -3,14 +3,17 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreCourseEventRequest; +use App\Http\Requests\Api\UpdateCourseEventRequest; +use App\Http\Resources\CourseEventResource; use App\Models\CourseEvent; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Symfony\Component\HttpFoundation\Response; #[Group(name: 'Kurs-Events', weight: 2)] @@ -22,13 +25,11 @@ class CourseEventController extends Controller * Liefert alle vom authentifizierten Nutzer erstellten Kurs-Events (inkl. zugehörigem * Kurs und Veranstaltungsort), absteigend nach Startdatum. Ideal für idempotente * Synchronisierung durch externe Clients. - * - * @return Collection */ #[QueryParameter(name: 'course_id', description: 'Filtert die Kurs-Events auf einen bestimmten Kurs.', required: false, type: 'integer')] - public function index(Request $request): Collection + public function index(Request $request): AnonymousResourceCollection { - return CourseEvent::query() + $courseEvents = CourseEvent::query() ->with(['course:id,name', 'venue:id,name']) ->where('created_by', $request->user()->id) ->when( @@ -37,6 +38,8 @@ class CourseEventController extends Controller ) ->orderByDesc('from') ->get(); + + return CourseEventResource::collection($courseEvents); } /** @@ -45,21 +48,13 @@ class CourseEventController extends Controller * Erlaubt einem authentifizierten Referenten, ein datiertes Kurs-Event programmatisch anzulegen. */ #[ResponseAttribute(status: 403, description: 'Nur Referenten (is_lecturer) dürfen Kurs-Events anlegen.')] - public function store(Request $request): JsonResponse + public function store(StoreCourseEventRequest $request): JsonResponse { - abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + $courseEvent = CourseEvent::create($request->validated()); - $validated = $request->validate([ - 'course_id' => ['required', 'integer', 'exists:courses,id'], - 'venue_id' => ['required', 'integer', 'exists:venues,id'], - 'from' => ['required', 'date'], - 'to' => ['required', 'date', 'after_or_equal:from'], - 'link' => ['required', 'url', 'max:255'], - ]); - - $courseEvent = CourseEvent::create($validated); - - return response()->json($courseEvent->fresh(), Response::HTTP_CREATED); + return CourseEventResource::make($courseEvent->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } /** @@ -68,23 +63,10 @@ class CourseEventController extends Controller * Aktualisiert ein Kurs-Event; nur für den Ersteller oder einen Super-Admin. */ #[ResponseAttribute(status: 403, description: 'Nur der Ersteller des Kurs-Events oder ein Super-Admin darf es ändern.')] - public function update(Request $request, CourseEvent $courseEvent): JsonResponse + public function update(UpdateCourseEventRequest $request, CourseEvent $courseEvent): CourseEventResource { - abort_unless( - (int) $courseEvent->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), - Response::HTTP_FORBIDDEN - ); + $courseEvent->update($request->validated()); - $validated = $request->validate([ - 'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'], - 'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'], - 'from' => ['sometimes', 'required', 'date'], - 'to' => ['sometimes', 'required', 'date', 'after_or_equal:from'], - 'link' => ['sometimes', 'required', 'url', 'max:255'], - ]); - - $courseEvent->update($validated); - - return response()->json($courseEvent->fresh()); + return CourseEventResource::make($courseEvent->fresh()); } } diff --git a/app/Http/Controllers/Api/LecturerController.php b/app/Http/Controllers/Api/LecturerController.php index e49ca97..c34d12d 100644 --- a/app/Http/Controllers/Api/LecturerController.php +++ b/app/Http/Controllers/Api/LecturerController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Concerns\FiltersNumericIds; use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreLecturerRequest; use App\Http\Requests\Api\UpdateLecturerRequest; +use App\Http\Requests\Api\UploadMediaRequest; use App\Http\Resources\LecturerResource; use App\Models\Course; use App\Models\Lecturer; @@ -142,6 +143,7 @@ class LecturerController extends Controller Gate::authorize('viewAny', Lecturer::class); $lecturers = Lecturer::query() + ->with('media') ->where('created_by', $request->user()->id) ->orderBy('name') ->get(); @@ -161,4 +163,22 @@ class LecturerController extends Controller return LecturerResource::make($lecturer); } + + /** + * Referenten-Avatar hochladen + * + * Lädt einen Avatar (multipart, Feld „file") in die singleFile-Collection „avatar" und + * ersetzt dabei ein vorhandenes Bild. Nur für den Ersteller oder einen Super-Admin. Die + * Antwort enthält die frische Avatar-URL. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf den Avatar ersetzen.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler (kein Bild, falscher MIME-Typ, zu groß oder zu große Abmessungen).')] + public function uploadAvatar(UploadMediaRequest $request, Lecturer $lecturer): LecturerResource + { + $lecturer->addMedia($request->file('file')->getRealPath()) + ->usingName($lecturer->name) + ->toMediaCollection('avatar'); + + return LecturerResource::make($lecturer->fresh()); + } } diff --git a/app/Http/Controllers/Api/MeetupController.php b/app/Http/Controllers/Api/MeetupController.php index 29039dc..9df16ac 100644 --- a/app/Http/Controllers/Api/MeetupController.php +++ b/app/Http/Controllers/Api/MeetupController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Concerns\FiltersNumericIds; use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreMeetupRequest; use App\Http\Requests\Api\UpdateMeetupRequest; +use App\Http\Requests\Api\UploadMediaRequest; use App\Http\Resources\MeetupResource; use App\Models\Meetup; use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; @@ -155,4 +156,22 @@ class MeetupController extends Controller return MeetupResource::make($meetup); } + + /** + * Meetup-Logo hochladen + * + * Lädt ein Logo (multipart, Feld „file") in die singleFile-Collection „logo" und ersetzt + * dabei ein vorhandenes Logo. Nur für den Ersteller oder einen Super-Admin. Die Antwort + * enthält die frische Logo-URL. + */ + #[Response(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Logo ersetzen.')] + #[Response(status: 422, description: 'Validierungsfehler (kein Bild, falscher MIME-Typ, zu groß oder zu große Abmessungen).')] + public function uploadLogo(UploadMediaRequest $request, Meetup $meetup): MeetupResource + { + $meetup->addMedia($request->file('file')->getRealPath()) + ->usingName($meetup->name) + ->toMediaCollection('logo'); + + return MeetupResource::make($meetup->fresh()); + } } diff --git a/app/Http/Controllers/Api/MeetupEventController.php b/app/Http/Controllers/Api/MeetupEventController.php index 1d05af6..f6f34e5 100644 --- a/app/Http/Controllers/Api/MeetupEventController.php +++ b/app/Http/Controllers/Api/MeetupEventController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\MeetupEvents\CreateMeetupEventSeries; use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreMeetupEventRequest; use App\Http\Requests\Api\UpdateMeetupEventRequest; @@ -87,12 +88,27 @@ class MeetupEventController extends Controller * * Erlaubt einem authentifizierten Nutzer, ein Meetup-Event programmatisch anzulegen. * Der Ersteller (created_by) wird automatisch gesetzt. + * + * Werden sowohl `recurrence_type` als auch `recurrence_end_date` übergeben, wird – wie im + * Web-Editor – eine Serie einzelner Termine erzeugt (gemeinsame Expansions-Action, harte + * Obergrenze von 100 Terminen) und die Antwort enthält die Liste aller erstellten Events. + * Ohne diese Felder entsteht ein einzelner Termin. */ #[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')] #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] - public function store(StoreMeetupEventRequest $request): JsonResponse + public function store(StoreMeetupEventRequest $request, CreateMeetupEventSeries $createSeries): JsonResponse { - $meetupEvent = MeetupEvent::create($request->validated()); + $validated = $request->validated(); + + if (! empty($validated['recurrence_type']) && ! empty($validated['recurrence_end_date'])) { + $events = $createSeries->handle($validated); + + return MeetupEventResource::collection($events) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + $meetupEvent = MeetupEvent::create($validated); return MeetupEventResource::make($meetupEvent->fresh()) ->response() diff --git a/app/Http/Requests/Api/StoreCourseEventRequest.php b/app/Http/Requests/Api/StoreCourseEventRequest.php new file mode 100644 index 0000000..b3b66c9 --- /dev/null +++ b/app/Http/Requests/Api/StoreCourseEventRequest.php @@ -0,0 +1,39 @@ +user()->can('create', CourseEvent::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'course_id' => ['required', 'integer', 'exists:courses,id'], + 'venue_id' => ['required', 'integer', 'exists:venues,id'], + 'from' => ['required', 'date'], + 'to' => ['required', 'date', 'after_or_equal:from'], + 'link' => ['required', 'url', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'course_id.exists' => 'Der angegebene Kurs existiert nicht.', + 'venue_id.exists' => 'Der angegebene Veranstaltungsort existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/StoreCourseRequest.php b/app/Http/Requests/Api/StoreCourseRequest.php new file mode 100644 index 0000000..ad0fa4e --- /dev/null +++ b/app/Http/Requests/Api/StoreCourseRequest.php @@ -0,0 +1,36 @@ +user()->can('create', Course::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'lecturer_id' => ['required', 'integer', 'exists:lecturers,id'], + 'description' => ['nullable', 'string'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'lecturer_id.exists' => 'Der angegebene Referent existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateCourseEventRequest.php b/app/Http/Requests/Api/UpdateCourseEventRequest.php new file mode 100644 index 0000000..c7426c2 --- /dev/null +++ b/app/Http/Requests/Api/UpdateCourseEventRequest.php @@ -0,0 +1,38 @@ +user()->can('update', $this->route('courseEvent')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'course_id' => ['sometimes', 'required', 'integer', 'exists:courses,id'], + 'venue_id' => ['sometimes', 'required', 'integer', 'exists:venues,id'], + 'from' => ['sometimes', 'required', 'date'], + 'to' => ['sometimes', 'required', 'date', 'after_or_equal:from'], + 'link' => ['sometimes', 'required', 'url', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'course_id.exists' => 'Der angegebene Kurs existiert nicht.', + 'venue_id.exists' => 'Der angegebene Veranstaltungsort existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateCourseRequest.php b/app/Http/Requests/Api/UpdateCourseRequest.php new file mode 100644 index 0000000..681924f --- /dev/null +++ b/app/Http/Requests/Api/UpdateCourseRequest.php @@ -0,0 +1,35 @@ +user()->can('update', $this->route('course')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'lecturer_id' => ['sometimes', 'required', 'integer', 'exists:lecturers,id'], + 'description' => ['sometimes', 'nullable', 'string'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'lecturer_id.exists' => 'Der angegebene Referent existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UploadMediaRequest.php b/app/Http/Requests/Api/UploadMediaRequest.php new file mode 100644 index 0000000..acb9366 --- /dev/null +++ b/app/Http/Requests/Api/UploadMediaRequest.php @@ -0,0 +1,46 @@ +boundModel(); + + return $model !== null && $this->user()->can('update', $model); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'file' => [ + 'required', + 'image', + 'mimes:jpeg,png,webp,avif', + 'max:5120', + 'dimensions:max_width=4000,max_height=4000', + ], + ]; + } + + /** + * The route-bound model whose media is being replaced (meetup, lecturer, course). + */ + protected function boundModel(): ?Model + { + foreach ($this->route()->parameters() as $parameter) { + if ($parameter instanceof Model) { + return $parameter; + } + } + + return null; + } +} diff --git a/app/Http/Resources/CourseEventResource.php b/app/Http/Resources/CourseEventResource.php new file mode 100644 index 0000000..8bbe0fc --- /dev/null +++ b/app/Http/Resources/CourseEventResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'course_id' => $this->course_id, + 'venue_id' => $this->venue_id, + 'from' => $this->from, + 'to' => $this->to, + 'link' => $this->link, + 'course' => $this->whenLoaded('course', fn (): array => [ + 'id' => $this->course->id, + 'name' => $this->course->name, + ]), + 'venue' => $this->whenLoaded('venue', fn (): array => [ + 'id' => $this->venue->id, + 'name' => $this->venue->name, + ]), + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/CourseResource.php b/app/Http/Resources/CourseResource.php new file mode 100644 index 0000000..903e962 --- /dev/null +++ b/app/Http/Resources/CourseResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'lecturer_id' => $this->lecturer_id, + 'description' => $this->description, + 'logo' => $this->getFirstMediaUrl('logo', 'thumb'), + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/LecturerResource.php b/app/Http/Resources/LecturerResource.php index ecfbd9b..7c693d9 100644 --- a/app/Http/Resources/LecturerResource.php +++ b/app/Http/Resources/LecturerResource.php @@ -32,6 +32,7 @@ class LecturerResource extends JsonResource 'node_id' => $this->node_id, 'paynym' => $this->paynym, 'team_id' => $this->team_id, + 'avatar' => $this->getFirstMediaUrl('avatar', 'thumb'), 'created_by' => $this->created_by, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/Policies/CourseEventPolicy.php b/app/Policies/CourseEventPolicy.php new file mode 100644 index 0000000..67bc4fc --- /dev/null +++ b/app/Policies/CourseEventPolicy.php @@ -0,0 +1,32 @@ +owns($user, $courseEvent); + } + + public function create(User $user): bool + { + return (bool) $user->is_lecturer; + } + + public function update(User $user, CourseEvent $courseEvent): bool + { + return $this->owns($user, $courseEvent); + } +} diff --git a/app/Policies/CoursePolicy.php b/app/Policies/CoursePolicy.php new file mode 100644 index 0000000..4133101 --- /dev/null +++ b/app/Policies/CoursePolicy.php @@ -0,0 +1,32 @@ +owns($user, $course); + } + + public function create(User $user): bool + { + return (bool) $user->is_lecturer; + } + + public function update(User $user, Course $course): bool + { + return $this->owns($user, $course); + } +} diff --git a/resources/views/livewire/meetups/create-edit-events.blade.php b/resources/views/livewire/meetups/create-edit-events.blade.php index 318223b..4098d5a 100644 --- a/resources/views/livewire/meetups/create-edit-events.blade.php +++ b/resources/views/livewire/meetups/create-edit-events.blade.php @@ -1,5 +1,6 @@ startDate . ' ' . $this->startTime, $timezone); $endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone); - // Use custom recurrence when dayOfWeek and dayPosition are set (e.g., "last Friday of month") - if ($this->recurrenceDayOfWeek && $this->recurrenceDayPosition) { - return $this->generateCustomRecurrenceDates($startDate, $endDate, $timezone, true); - } - - // For weekly recurrence with a specific day of week (no position), - // shift start date to the next occurrence of that weekday - if ($this->recurrenceType === RecurrenceType::Weekly && $this->recurrenceDayOfWeek) { - $dayOfWeekNumber = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); - if ($dayOfWeekNumber !== null) { - $adjustedStartDate = $startDate->copy(); - // Find the next occurrence of the specified weekday - while ($adjustedStartDate->dayOfWeek !== $dayOfWeekNumber) { - $adjustedStartDate->addDay(); - } - // Generate weekly dates from the adjusted start - $dates = []; - $currentDate = $adjustedStartDate->copy(); - while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { - $dates[] = [ - 'date' => $currentDate->copy(), - 'formatted' => $currentDate->translatedFormat('l, d.m.Y'), - 'time' => $currentDate->format('H:i'), - ]; - $currentDate->addWeek(); - } - return $dates; - } - } - - // Default: generate dates based on recurrence type - $currentDate = $startDate->copy(); - $dates = []; - - while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { - $dates[] = [ - 'date' => $currentDate->copy(), - 'formatted' => $currentDate->translatedFormat('l, d.m.Y'), - 'time' => $currentDate->format('H:i'), - ]; - - if ($this->recurrenceType === RecurrenceType::Weekly) { - $currentDate->addWeek(); - } else { - $currentDate->addMonth(); - } - } - - return $dates; + return array_map(fn (\Carbon\Carbon $date): array => [ + 'date' => $date, + 'formatted' => $date->translatedFormat('l, d.m.Y'), + 'time' => $date->format('H:i'), + ], $this->generateEventDates($startDate, $endDate)); } catch (\Exception $e) { return []; } } - private function generateCustomRecurrenceDates(\Carbon\Carbon $startDate, \Carbon\Carbon $endDate, string $timezone, bool $formatted = false): array - { - $dates = []; - - // Start from the beginning of the month containing startDate - $currentDate = $startDate->copy()->startOfMonth(); - // Preserve the time from startDate for the occurrences - $time = $startDate->format('H:i:s'); - - while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { - $occurrenceDate = $this->findNextOccurrence($currentDate, $timezone); - - if ($occurrenceDate && $occurrenceDate->lessThanOrEqualTo($endDate)) { - // Set the time from startDate, preserving the date - $occurrenceWithTime = $occurrenceDate->copy()->setTimeFrom($startDate); - - // Only add if this is after or on the start date - if ($occurrenceWithTime->gte($startDate)) { - if ($formatted) { - $dates[] = [ - 'date' => $occurrenceWithTime, - 'formatted' => $occurrenceWithTime->translatedFormat('l, d.m.Y'), - 'time' => $time, - ]; - } else { - $dates[] = $occurrenceWithTime; - } - } - - // Move to the next month - $currentDate = $currentDate->copy()->addMonth(); - } else { - break; - } - } - - return $dates; - } - - private function findNextOccurrence(\Carbon\Carbon $currentDate, string $timezone): ?\Carbon\Carbon - { - if (!$this->recurrenceDayOfWeek || !$this->recurrenceDayPosition) { - return $currentDate; - } - - $dayOfWeek = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); - $dayPosition = $this->getDayPositionNumber($this->recurrenceDayPosition); - - if ($dayOfWeek === null || $dayPosition === null) { - return $currentDate; - } - - // Find the Nth dayOfWeek in the current month - $date = $currentDate->copy()->startOfMonth(); - - if ($dayPosition === -1) { - return $date->lastOfMonth($dayOfWeek)->setTime($currentDate->hour, $currentDate->minute, $currentDate->second); - } - - $count = 0; - while ($date->month === $currentDate->month) { - if ($date->dayOfWeek === $dayOfWeek) { - $count++; - if ($count === $dayPosition) { - return $date->copy()->setTime($currentDate->hour, $currentDate->minute, $currentDate->second); - } - } - $date->addDay(); - } - - // If we didn't find enough occurrences in this month, return null - return null; - } - - private function getDayOfWeekNumber(string $day): ?int - { - return match (strtolower($day)) { - 'monday', 'montag' => \Carbon\Carbon::MONDAY, - 'tuesday', 'dienstag' => \Carbon\Carbon::TUESDAY, - 'wednesday', 'mittwoch' => \Carbon\Carbon::WEDNESDAY, - 'thursday', 'donnerstag' => \Carbon\Carbon::THURSDAY, - 'friday', 'freitag' => \Carbon\Carbon::FRIDAY, - 'saturday', 'samstag' => \Carbon\Carbon::SATURDAY, - 'sunday', 'sonntag' => \Carbon\Carbon::SUNDAY, - default => null, - }; - } - - private function getDayPositionNumber(string $position): ?int - { - return match (strtolower($position)) { - 'first', 'erster' => 1, - 'second', 'zweiter' => 2, - 'third', 'dritter' => 3, - 'fourth', 'vierter' => 4, - 'last', 'letzter' => -1, - default => null, - }; - } - public function getRecurrenceTypesProperty(): array { return [ @@ -338,7 +195,7 @@ class extends Component { $eventsCreated = 0; - $dates = $this->generateEventDates($startDate, $endDate, $timezone); + $dates = $this->generateEventDates($startDate, $endDate); foreach ($dates as $date) { $utcDateTime = $date->copy()->setTimezone('UTC'); @@ -359,47 +216,18 @@ class extends Component { session()->flash('status', __(':count Events erfolgreich erstellt!', ['count' => $eventsCreated])); } - private function generateEventDates(\Carbon\Carbon $startDate, \Carbon\Carbon $endDate, string $timezone): array + /** + * @return array + */ + private function generateEventDates(\Carbon\Carbon $startDate, \Carbon\Carbon $endDate): array { - // Use custom recurrence when dayOfWeek and dayPosition are set (e.g., "last Friday of month") - if ($this->recurrenceDayOfWeek && $this->recurrenceDayPosition) { - return $this->generateCustomRecurrenceDates($startDate, $endDate, $timezone); - } - - // For weekly recurrence with a specific day of week (no position), - // shift start date to the next occurrence of that weekday - if ($this->recurrenceType === RecurrenceType::Weekly && $this->recurrenceDayOfWeek) { - $dayOfWeekNumber = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); - if ($dayOfWeekNumber !== null) { - $adjustedStartDate = $startDate->copy(); - while ($adjustedStartDate->dayOfWeek !== $dayOfWeekNumber) { - $adjustedStartDate->addDay(); - } - $dates = []; - $currentDate = $adjustedStartDate->copy(); - while ($currentDate->lessThanOrEqualTo($endDate)) { - $dates[] = $currentDate->copy(); - $currentDate->addWeek(); - } - return $dates; - } - } - - // Default: generate dates based on recurrence type - $dates = []; - $currentDate = $startDate->copy(); - - while ($currentDate->lessThanOrEqualTo($endDate)) { - $dates[] = $currentDate->copy(); - - if ($this->recurrenceType === RecurrenceType::Weekly) { - $currentDate->addWeek(); - } else { - $currentDate->addMonth(); - } - } - - return $dates; + return app(ExpandRecurrenceSeries::class)->handle( + $startDate, + $endDate, + $this->recurrenceType, + $this->recurrenceDayOfWeek ?: null, + $this->recurrenceDayPosition ?: null, + ); } public function delete(): void diff --git a/routes/api.php b/routes/api.php index a1fd208..a8b8a77 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,6 +47,8 @@ Route::middleware('auth:sanctum') ->name('courses.store'); Route::patch('courses/{course}', [CourseController::class, 'update']) ->name('courses.update'); + Route::post('courses/{course}/logo', [CourseController::class, 'uploadLogo']) + ->name('courses.logo'); Route::get('course-events', [CourseEventController::class, 'index']) ->name('course-events.index'); @@ -57,6 +59,7 @@ Route::middleware('auth:sanctum') Route::post('lecturers', [LecturerController::class, 'store'])->name('lecturers.store'); Route::patch('lecturers/{lecturer}', [LecturerController::class, 'update'])->name('lecturers.update'); + Route::post('lecturers/{lecturer}/avatar', [LecturerController::class, 'uploadAvatar'])->name('lecturers.avatar'); Route::get('my-lecturers', [LecturerController::class, 'mine'])->name('lecturers.mine'); Route::get('my-lecturers/{lecturer}', [LecturerController::class, 'mineShow'])->name('lecturers.mine.show'); @@ -72,6 +75,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::post('meetup/{meetup}/logo', [MeetupController::class, 'uploadLogo'])->name('meetup.logo'); 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'); diff --git a/tests/Feature/Api/CourseEventApiTest.php b/tests/Feature/Api/CourseEventApiTest.php index c50aa3c..5969542 100644 --- a/tests/Feature/Api/CourseEventApiTest.php +++ b/tests/Feature/Api/CourseEventApiTest.php @@ -36,7 +36,8 @@ it('lets a lecturer create a course', function () { 'description' => 'Hardware-Wallet selbst bauen.', ]) ->assertCreated() - ->assertJsonPath('name', 'Specter Shield Lite Workshop'); + ->assertJsonPath('data.name', 'Specter Shield Lite Workshop') + ->assertJsonPath('data.created_by', $user->id); $this->assertDatabaseHas('courses', [ 'name' => 'Specter Shield Lite Workshop', @@ -44,6 +45,36 @@ it('lets a lecturer create a course', function () { ]); }); +it('fails course validation without required fields', function () { + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->postJson('/api/courses', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'lecturer_id']); +}); + +it('lets the owner update their course', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $course = Course::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/courses/'.$course->id, [ + 'name' => 'Aktualisierter Kurs', + ]) + ->assertSuccessful() + ->assertJsonPath('data.name', 'Aktualisierter Kurs'); +}); + +it('forbids updating a course owned by someone else', function () { + $owner = User::factory()->lecturer()->create(); + $course = Course::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->patchJson('/api/courses/'.$course->id, [ + 'name' => 'Übernommen', + ])->assertForbidden(); +}); + it('lets a lecturer create a course event', function () { Sanctum::actingAs($user = User::factory()->lecturer()->create()); $course = Course::factory()->create(); @@ -57,7 +88,7 @@ it('lets a lecturer create a course event', function () { 'link' => 'https://clavastack.com/produkt/specter-shield-lite-workshop', ]) ->assertCreated() - ->assertJsonPath('course_id', $course->id); + ->assertJsonPath('data.course_id', $course->id); $this->assertDatabaseHas('course_events', [ 'course_id' => $course->id, @@ -66,6 +97,20 @@ it('lets a lecturer create a course event', function () { ]); }); +it('forbids a non-lecturer from creating a course event', function () { + Sanctum::actingAs(User::factory()->create(['is_lecturer' => false])); + $course = Course::factory()->create(); + $venue = Venue::factory()->create(); + + $this->postJson('/api/course-events', [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'from' => '2026-07-01 18:00:00', + 'to' => '2026-07-01 21:00:00', + 'link' => 'https://example.com/event', + ])->assertForbidden(); +}); + it('fails course event validation without required fields', function () { Sanctum::actingAs(User::factory()->lecturer()->create()); @@ -84,8 +129,8 @@ it('returns only the authenticated user\'s own course events', function () { $response = $this->getJson('/api/course-events'); $response->assertSuccessful(); - expect($response->json())->toHaveCount(2); - collect($response->json())->each( + expect($response->json('data'))->toHaveCount(2); + collect($response->json('data'))->each( fn ($event) => expect($event['created_by'])->toBe($user->id) ); }); @@ -99,8 +144,8 @@ it('filters own course events by course_id', function () { $response = $this->getJson('/api/course-events?course_id='.$event->course_id); $response->assertSuccessful(); - expect($response->json())->toHaveCount(1) - ->and($response->json('0.id'))->toBe($event->id); + expect($response->json('data'))->toHaveCount(1) + ->and($response->json('data.0.id'))->toBe($event->id); }); it('lets the owner update their course event', function () { @@ -111,7 +156,7 @@ it('lets the owner update their course event', function () { 'link' => 'https://einundzwanzig.space/courses/updated', ]) ->assertSuccessful() - ->assertJsonPath('link', 'https://einundzwanzig.space/courses/updated'); + ->assertJsonPath('data.link', 'https://einundzwanzig.space/courses/updated'); }); it('forbids updating a course event owned by someone else', function () { diff --git a/tests/Feature/Api/MediaUploadTest.php b/tests/Feature/Api/MediaUploadTest.php new file mode 100644 index 0000000..6ffb51b --- /dev/null +++ b/tests/Feature/Api/MediaUploadTest.php @@ -0,0 +1,108 @@ +create()); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + $response = $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => UploadedFile::fake()->image('logo.png', 512, 512), + ]); + + $response->assertSuccessful(); + expect($response->json('data.logo'))->not->toBeEmpty(); + expect($meetup->fresh()->getFirstMedia('logo'))->not->toBeNull(); +}); + +it('lets the owner upload a lecturer avatar', function () { + Sanctum::actingAs($user = User::factory()->create()); + $lecturer = Lecturer::factory()->create(['created_by' => $user->id]); + + $response = $this->postJson('/api/lecturers/'.$lecturer->id.'/avatar', [ + 'file' => UploadedFile::fake()->image('avatar.jpg', 512, 512), + ]); + + $response->assertSuccessful(); + expect($response->json('data.avatar'))->not->toBeEmpty(); + expect($lecturer->fresh()->getFirstMedia('avatar'))->not->toBeNull(); +}); + +it('lets the owner upload a course logo', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $course = Course::factory()->create(['created_by' => $user->id]); + + $response = $this->postJson('/api/courses/'.$course->id.'/logo', [ + 'file' => UploadedFile::fake()->image('logo.webp', 512, 512), + ]); + + $response->assertSuccessful(); + expect($response->json('data.logo'))->not->toBeEmpty(); + expect($course->fresh()->getFirstMedia('logo'))->not->toBeNull(); +}); + +it('replaces the previous logo on re-upload (singleFile)', function () { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => UploadedFile::fake()->image('first.png', 256, 256), + ])->assertSuccessful(); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => UploadedFile::fake()->image('second.png', 256, 256), + ])->assertSuccessful(); + + expect($meetup->fresh()->getMedia('logo'))->toHaveCount(1); +}); + +it('rejects a guest uploading a logo', function () { + $meetup = Meetup::factory()->create(); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => UploadedFile::fake()->image('logo.png', 256, 256), + ])->assertUnauthorized(); +}); + +it('forbids uploading a logo to a meetup owned by someone else', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => UploadedFile::fake()->image('logo.png', 256, 256), + ])->assertForbidden(); +}); + +it('rejects invalid uploads', function (UploadedFile $file) { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', [ + 'file' => $file, + ])->assertUnprocessable()->assertJsonValidationErrors('file'); +})->with([ + 'wrong mime' => fn () => UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'), + 'too large in size' => fn () => UploadedFile::fake()->image('huge.png', 256, 256)->size(6000), + 'too large in dimensions' => fn () => UploadedFile::fake()->image('giant.png', 4200, 4200), +]); + +it('requires a file', function () { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + $this->postJson('/api/meetup/'.$meetup->id.'/logo', []) + ->assertUnprocessable() + ->assertJsonValidationErrors('file'); +}); diff --git a/tests/Feature/Api/MeetupEventSeriesTest.php b/tests/Feature/Api/MeetupEventSeriesTest.php new file mode 100644 index 0000000..abbfe0e --- /dev/null +++ b/tests/Feature/Api/MeetupEventSeriesTest.php @@ -0,0 +1,93 @@ +create()); + $meetup = Meetup::factory()->create(); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => $meetup->id, + 'start' => '2026-07-01 18:00:00', + 'location' => 'Marktplatz', + 'description' => 'Wöchentlicher Stammtisch', + 'link' => 'https://einundzwanzig.space', + 'recurrence_type' => 'weekly', + 'recurrence_end_date' => '2026-07-29 18:00:00', + ]); + + // 2026-07-01, 07-08, 07-15, 07-22, 07-29 = 5 occurrences + $response->assertCreated()->assertJsonCount(5, 'data'); + + expect(MeetupEvent::where('meetup_id', $meetup->id)->count())->toBe(5); + + $this->assertDatabaseHas('meetup_events', [ + 'meetup_id' => $meetup->id, + 'created_by' => $user->id, + 'recurrence_type' => null, + ]); +}); + +it('creates a monthly series of individual events', function () { + Sanctum::actingAs(User::factory()->create()); + $meetup = Meetup::factory()->create(); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => $meetup->id, + 'start' => '2026-07-01 18:00:00', + 'link' => 'https://einundzwanzig.space', + 'recurrence_type' => 'monthly', + 'recurrence_end_date' => '2026-10-01 18:00:00', + ]); + + // 2026-07-01, 08-01, 09-01, 10-01 = 4 occurrences + $response->assertCreated()->assertJsonCount(4, 'data'); +}); + +it('caps the series at 100 occurrences', function () { + Sanctum::actingAs(User::factory()->create()); + $meetup = Meetup::factory()->create(); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => $meetup->id, + 'start' => '2026-01-01 18:00:00', + 'link' => 'https://einundzwanzig.space', + 'recurrence_type' => 'weekly', + 'recurrence_end_date' => '2030-01-01 18:00:00', + ]); + + $response->assertCreated()->assertJsonCount(100, 'data'); +}); + +it('still creates a single event without recurrence fields', function () { + Sanctum::actingAs(User::factory()->create()); + $meetup = Meetup::factory()->create(); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => $meetup->id, + 'start' => '2026-08-01 18:00:00', + 'location' => 'Marktplatz', + ]); + + $response->assertCreated()->assertJsonPath('data.location', 'Marktplatz'); + + expect(MeetupEvent::where('meetup_id', $meetup->id)->count())->toBe(1); +}); + +it('creates a single event when recurrence_type is set but no end date', function () { + Sanctum::actingAs(User::factory()->create()); + $meetup = Meetup::factory()->create(); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => $meetup->id, + 'start' => '2026-08-01 18:00:00', + 'recurrence_type' => 'weekly', + ]); + + $response->assertCreated()->assertJsonPath('data.recurrence_type', 'weekly'); + + expect(MeetupEvent::where('meetup_id', $meetup->id)->count())->toBe(1); +}); diff --git a/tests/Feature/Meetups/CreateMeetupEventSeriesTest.php b/tests/Feature/Meetups/CreateMeetupEventSeriesTest.php new file mode 100644 index 0000000..354ccfe --- /dev/null +++ b/tests/Feature/Meetups/CreateMeetupEventSeriesTest.php @@ -0,0 +1,42 @@ +create(); + + Livewire::test('meetups.create-edit-events', ['meetup' => $meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-07-01') + ->set('startTime', '18:00') + ->set('endDate', '2026-07-29') + ->set('recurrenceType', RecurrenceType::Weekly->value) + ->set('location', 'Marktplatz') + ->set('description', 'Wöchentlicher Stammtisch') + ->set('link', 'https://einundzwanzig.space') + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(); + + // The web editor parses the end date at midnight, so the occurrence on the end + // date's evening falls outside the range: 2026-07-01, 07-08, 07-15, 07-22 = 4. + // The shared action yields the identical result for the same inputs. + expect(MeetupEvent::where('meetup_id', $meetup->id)->count())->toBe(4); +}); + +it('previews the same dates it will create', function () { + actingAsUser(); + $meetup = Meetup::factory()->create(); + + Livewire::test('meetups.create-edit-events', ['meetup' => $meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-07-01') + ->set('startTime', '18:00') + ->set('endDate', '2026-07-29') + ->set('recurrenceType', RecurrenceType::Weekly->value) + ->assertSet('previewDates', fn ($dates) => count($dates) === 4); +}); diff --git a/tests/Unit/ExpandRecurrenceSeriesTest.php b/tests/Unit/ExpandRecurrenceSeriesTest.php new file mode 100644 index 0000000..5cb305f --- /dev/null +++ b/tests/Unit/ExpandRecurrenceSeriesTest.php @@ -0,0 +1,76 @@ +action = new ExpandRecurrenceSeries; +}); + +it('expands a basic weekly series', function () { + $dates = $this->action->handle( + Carbon::parse('2026-07-01 18:00:00'), + Carbon::parse('2026-07-29 18:00:00'), + RecurrenceType::Weekly, + ); + + expect($dates)->toHaveCount(5) + ->and($dates[0]->format('Y-m-d H:i'))->toBe('2026-07-01 18:00') + ->and($dates[4]->format('Y-m-d H:i'))->toBe('2026-07-29 18:00'); +}); + +it('expands a basic monthly series', function () { + $dates = $this->action->handle( + Carbon::parse('2026-07-01 18:00:00'), + Carbon::parse('2026-10-01 18:00:00'), + RecurrenceType::Monthly, + ); + + expect($dates)->toHaveCount(4) + ->and($dates[1]->format('Y-m-d'))->toBe('2026-08-01'); +}); + +it('shifts a weekly series to the requested weekday', function () { + // 2026-07-01 is a Wednesday; ask for Friday occurrences. + $dates = $this->action->handle( + Carbon::parse('2026-07-01 18:00:00'), + Carbon::parse('2026-07-31 18:00:00'), + RecurrenceType::Weekly, + 'friday', + ); + + expect($dates)->not->toBeEmpty(); + foreach ($dates as $date) { + expect($date->dayOfWeek)->toBe(Carbon::FRIDAY); + } + // First Friday on/after 2026-07-01 is 2026-07-03. + expect($dates[0]->format('Y-m-d'))->toBe('2026-07-03'); +}); + +it('expands a custom "last Friday of the month" rule', function () { + $dates = $this->action->handle( + Carbon::parse('2026-07-01 19:00:00'), + Carbon::parse('2026-09-30 19:00:00'), + RecurrenceType::Monthly, + 'friday', + 'last', + ); + + // Last Fridays: 2026-07-31, 2026-08-28, 2026-09-25 + expect($dates)->toHaveCount(3) + ->and($dates[0]->format('Y-m-d'))->toBe('2026-07-31') + ->and($dates[1]->format('Y-m-d'))->toBe('2026-08-28') + ->and($dates[2]->format('Y-m-d'))->toBe('2026-09-25') + ->and($dates[0]->format('H:i'))->toBe('19:00'); +}); + +it('enforces the hard cap of 100 occurrences', function () { + $dates = $this->action->handle( + Carbon::parse('2026-01-01 18:00:00'), + Carbon::parse('2030-01-01 18:00:00'), + RecurrenceType::Weekly, + ); + + expect($dates)->toHaveCount(ExpandRecurrenceSeries::MAX_OCCURRENCES); +});