Files
einundzwanzig-app/API_PARITAET_PLAN.md
HolgerHatGarKeineNode 4b6cf95932 🖼️ Implement Logo/Avatar Uploads & Recurrence UI in Mobile App
- **A1**: Added image uploads (Meetup logo, Lecturer avatar, Course logo) via `HandlesImageUpload` with reusable `<x-image-picker>` component and Saloon multipart requests.
- **A2**: Introduced Recurrence UI in Event Editor with support for `weekly`, `monthly`, and `custom` recurrence types, aligning with portal capabilities.
- Fixed `myCourseEvents` API response handling (`data` wrapper) for consistency.
2026-06-15 19:32:56 +02:00

20 KiB
Raw Permalink Blame History

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-uploadaddMedia(...)->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.

  • 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.
  • 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).
  • 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.
  • 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().
  • 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).
  • 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.

  • P1.1 Entscheidung umsetzen: dedizierte Upload-Endpunkte statt multipart-in-Store (siehe Log). Routen in der auth:sanctum-Gruppe:
    • POST /api/meetup/{meetup}/logoMeetupController::uploadLogo
    • POST /api/lecturers/{lecturer}/avatarLecturerController::uploadAvatar
    • POST /api/courses/{course}/logoCourseController::uploadLogo
    • (optional, falls App es braucht: …/images für Multi-Collections bei Lecturer/Course/Venue — erst bei Bedarf, siehe Offene Fragen.)
  • 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).
  • 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).
  • 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?
  • 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.

  • 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).
  • P2.2 Das Web auf die neue Action umstellen (Refactor ohne Verhaltensänderung — Preview + Speichern nutzen dieselbe Quelle). Sichert echte Parität + verhindert Drift.
  • 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.
  • 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.)
  • 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.

  • 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).
  • 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.
  • P3.3 Fehler-Antworten dokumentieren (401/403/422) — wo Scramble sie nicht automatisch ableitet, via Attribut/PHPDoc ergänzen.
  • 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).
  • 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 🔐

  • 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.
  • 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) + Kurs-Logo. Umgesetzt: gemeinsamer HandlesImageUpload-Concern (Camera getPhoto/pickImages#[OnNative]-Events, je Editor per imageUploadKey() korreliert), wiederverwendbare <x-image-picker>-Komponente, drei multipart-Saloon-Requests (UploadMeetupLogo/UploadLecturerAvatar/UploadCourseLogoRequest über die Basis UploadMediaRequest, Feld file), PortalWriter::uploadMeetupLogo/uploadLecturerAvatar/uploadCourseLogo (mit Datei-Lesbarkeits-Guard + Cache-Invalidierung). Zweistufig: Stammdaten per JSON anlegen → ID aus der data-gewrappten Response → Bild separat hochladen; schlägt der Upload fehl, bleibt der Datensatz gespeichert (Warn-Toast). Tests: je Editor Create-/Edit-Upload + „kein Bild → kein Upload".
  • 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. Umgesetzt: EventForm um repeats/recurrence_type/recurrence_day_of_week/recurrence_day_position/recurrence_end_date erweitert; UI nur beim Anlegen (Serie aus einzelnen Terminen). Bewusst nur die Typen exponiert, die das Portal real expandiertweekly, monthly und custom (N-ter Wochentag im Monat); daily/yearly weggelassen, da ExpandRecurrenceSeries für sie auf addMonth zurückfällt (siehe Log). Client-Validierung spiegelt die Portal-Bedingung (Typ + Enddatum ≥ Start, custom braucht Wochentag + Position). Tests: weekly/custom-Serie-Payload, Pflichtfelder, Enddatum-vor-Start, Einzeltermin sendet keine recurrence_*.
  • A3 Optional, nur falls P-Backlog gezogen wird: Kurs-Kategorie-Auswahl (erst wenn das Web sie bekommt — sonst keine Parität). Weiter offen (Web hat keine Kategorie-Verwaltung).

Regressionsfix (App, nicht im ursprünglichen A-Scope): Da P0 die Course/CourseEvent-Write-und den my-course-events-Lese-Endpunkt auf JsonResource (= data-Wrapper) gehoben hat, las PortalApi::myCourseEvents() das data nicht aus (alle anderen mine-Pfade tun es). Auf den json('data')-Extraktor umgestellt + die vier betroffenen App-Test-Mocks data-gewrappt. Die übrigen Course-Lesepfade (courses/course/myCourses = index/show) blieben rohe Arrays → unverändert.


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

  • 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.)
  • Recurrence-Persistenz (P2.4): Entschieden — nur Einzeltermine (1:1 wie Web heute), keine recurrence_*-Metadaten am Master-Event. iCal/rrule bleibt Backlog.
  • 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
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
2026-06-15 Phase A (App-Nachzug) umgesetzt: A1 (Upload) + A2 (Recurrence) Mobile-App einundzwanzig-mobile-app zieht die in P1/P2 entsperrten Fähigkeiten nach. Logo/Avatar/Kurs-Logo per NativePHP-Camera + zweistufigem multipart-Upload; Recurrence-UI im Termin-Editor. 311 App-Tests grün, Pint + Larastan (Level 7) sauber. A3 (Kategorien) bleibt offen, solange das Web keine Kategorie-Verwaltung hat
2026-06-15 App exponiert nur weekly/monthly/custom, nicht daily/yearly ExpandRecurrenceSeries::handle() behandelt nur Weekly (addWeek) gesondert; daily/monthly/yearly fallen alle auf addMonth zurück. „daily"/„yearly" würden also still als monatliche Kadenz expandieren — irreführend. Die App bietet daher nur die Typen an, die real die erwartete Kadenz erzeugen (custom = N-ter Wochentag via dayOfWeek+dayPosition). Optionaler Portal-Fix: daily (addDay) + yearly (addYear) in der Action ergänzen, dann kann die App sie nachziehen