- 🏗️ Introduced CoursePolicy and CourseEventPolicy for authorization.

-  Added `StoreCourseRequest` and `UpdateCourseRequest` for structured validation.
-  Introduced `StoreCourseEventRequest` and `UpdateCourseEventRequest` for consistent request validation.
- 🖼️ Created `CourseResource` and `CourseEventResource` for API responses.
- 🔄 Refactored `CourseController` and `CourseEventController` to use Policies and FormRequests.
-  Added dedicated `uploadLogo` and `uploadAvatar` API endpoints with shared media validation.
- 🚀 Improved API by aligning Course and CourseEvent behavior with other entities.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-15 15:06:07 +02:00
parent 119deb4f5c
commit 1518611bdb
25 changed files with 1186 additions and 256 deletions
+154
View File
@@ -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 + 12 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 | **P0P4 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 |
@@ -0,0 +1,43 @@
<?php
namespace App\Actions\MeetupEvents;
use App\Enums\RecurrenceType;
use App\Models\MeetupEvent;
use Carbon\Carbon;
use Illuminate\Support\Collection;
/**
* Persists a recurrence rule as concrete individual MeetupEvent records,
* mirroring the Livewire editor: each occurrence is stored as a standalone
* event without recurrence metadata.
*/
class CreateMeetupEventSeries
{
public function __construct(private ExpandRecurrenceSeries $expandRecurrenceSeries) {}
/**
* @param array<string, mixed> $data Validated StoreMeetupEventRequest payload.
* @return Collection<int, MeetupEvent>
*/
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' => [],
]));
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Actions\MeetupEvents;
use App\Enums\RecurrenceType;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Closure;
/**
* Expands a recurrence rule into the concrete list of start datetimes.
*
* This is the single source of truth shared by the Livewire event editor
* (preview + persist) and the REST API. It is timezone-agnostic: it operates
* on the Carbon instances it receives and preserves their timezone, leaving
* any UTC normalization to the caller.
*/
class ExpandRecurrenceSeries
{
/**
* Hard upper bound on the number of generated occurrences.
*/
public const MAX_OCCURRENCES = 100;
/**
* @return array<int, Carbon>
*/
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<int, Carbon>
*/
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<int, Carbon>
*/
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,
};
}
}
+29 -23
View File
@@ -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]
@@ -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<int, CourseEvent>
*/
#[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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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()
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Api;
use App\Models\CourseEvent;
use Illuminate\Foundation\Http\FormRequest;
class StoreCourseEventRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', CourseEvent::class);
}
/**
* @return array<string, array<int, string>>
*/
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<string, string>
*/
public function messages(): array
{
return [
'course_id.exists' => 'Der angegebene Kurs existiert nicht.',
'venue_id.exists' => 'Der angegebene Veranstaltungsort existiert nicht.',
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Api;
use App\Models\Course;
use Illuminate\Foundation\Http\FormRequest;
class StoreCourseRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Course::class);
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'lecturer_id' => ['required', 'integer', 'exists:lecturers,id'],
'description' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'lecturer_id.exists' => 'Der angegebene Referent existiert nicht.',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCourseEventRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('courseEvent'));
}
/**
* @return array<string, array<int, string>>
*/
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<string, string>
*/
public function messages(): array
{
return [
'course_id.exists' => 'Der angegebene Kurs existiert nicht.',
'venue_id.exists' => 'Der angegebene Veranstaltungsort existiert nicht.',
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCourseRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('course'));
}
/**
* @return array<string, array<int, string>>
*/
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<string, string>
*/
public function messages(): array
{
return [
'lecturer_id.exists' => 'Der angegebene Referent existiert nicht.',
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
class UploadMediaRequest extends FormRequest
{
public function authorize(): bool
{
$model = $this->boundModel();
return $model !== null && $this->user()->can('update', $model);
}
/**
* @return array<string, array<int, string>>
*/
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;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Resources;
use App\Models\CourseEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin CourseEvent
*/
class CourseEventResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use App\Models\Course;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Course
*/
class CourseResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}
+1
View File
@@ -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,
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Policies;
use App\Models\CourseEvent;
use App\Models\User;
use App\Policies\Concerns\ChecksCreatorOwnership;
class CourseEventPolicy
{
use ChecksCreatorOwnership;
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, CourseEvent $courseEvent): bool
{
return $this->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);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Policies;
use App\Models\Course;
use App\Models\User;
use App\Policies\Concerns\ChecksCreatorOwnership;
class CoursePolicy
{
use ChecksCreatorOwnership;
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Course $course): bool
{
return $this->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);
}
}
@@ -1,5 +1,6 @@
<?php
use App\Actions\MeetupEvents\ExpandRecurrenceSeries;
use App\Attributes\SeoDataAttribute;
use App\Enums\RecurrenceType;
use App\Models\Meetup;
@@ -45,160 +46,16 @@ class extends Component {
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d H:i', $this->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<int, \Carbon\Carbon>
*/
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
+4
View File
@@ -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');
+52 -7
View File
@@ -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 () {
+108
View File
@@ -0,0 +1,108 @@
<?php
use App\Models\Course;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
beforeEach(function () {
Storage::fake('public');
});
it('lets the owner upload a meetup logo', function () {
Sanctum::actingAs($user = User::factory()->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');
});
@@ -0,0 +1,93 @@
<?php
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('creates a weekly series of individual events', function () {
Sanctum::actingAs($user = User::factory()->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);
});
@@ -0,0 +1,42 @@
<?php
use App\Enums\RecurrenceType;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use Livewire\Livewire;
it('creates a weekly series via the web editor using the shared action', 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)
->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);
});
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Actions\MeetupEvents\ExpandRecurrenceSeries;
use App\Enums\RecurrenceType;
use Carbon\Carbon;
beforeEach(function () {
$this->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);
});