diff --git a/app/Http/Controllers/Api/CityController.php b/app/Http/Controllers/Api/CityController.php index e57387c..2c1f1e6 100644 --- a/app/Http/Controllers/Api/CityController.php +++ b/app/Http/Controllers/Api/CityController.php @@ -3,13 +3,19 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreCityRequest; +use App\Http\Requests\Api\UpdateCityRequest; +use App\Http\Resources\CityResource; use App\Models\City; -use App\Models\Lecturer; -use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Gate; +use Symfony\Component\HttpFoundation\Response; #[Group(name: 'Stammdaten', weight: 5)] class CityController extends Controller @@ -41,27 +47,64 @@ class CityController extends Controller ->get(); } - #[ExcludeRouteFromDocs] - public function store(Request $request) + /** + * Stadt anlegen + * + * Erlaubt einem authentifizierten Nutzer, eine Stadt programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch gesetzt. + */ + #[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function store(StoreCityRequest $request): JsonResponse { - // + $city = City::create($request->validated()); + + return CityResource::make($city->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } - #[ExcludeRouteFromDocs] - public function show(Lecturer $lecturer) + /** + * Stadt aktualisieren + * + * Aktualisiert eine Stadt; nur fuer den Ersteller oder einen Super-Admin. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf die Stadt aendern.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function update(UpdateCityRequest $request, City $city): CityResource { - // + $city->update($request->validated()); + + return CityResource::make($city->fresh()); } - #[ExcludeRouteFromDocs] - public function update(Request $request, Lecturer $lecturer) + /** + * Eigene Staedte auflisten + * + * Liefert alle vom authentifizierten Nutzer erstellten Staedte, alphabetisch sortiert. + */ + public function mine(Request $request): AnonymousResourceCollection { - // + Gate::authorize('viewAny', City::class); + + $cities = City::query() + ->where('created_by', $request->user()->id) + ->orderBy('name') + ->get(); + + return CityResource::collection($cities); } - #[ExcludeRouteFromDocs] - public function destroy(Lecturer $lecturer) + /** + * Eigene Stadt anzeigen + * + * Zeigt eine einzelne, vom authentifizierten Nutzer erstellte Stadt. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf die Stadt sehen.')] + public function mineShow(City $city): CityResource { - // + Gate::authorize('view', $city); + + return CityResource::make($city); } } diff --git a/app/Http/Controllers/Api/LecturerController.php b/app/Http/Controllers/Api/LecturerController.php index 4346dc9..faac6b4 100644 --- a/app/Http/Controllers/Api/LecturerController.php +++ b/app/Http/Controllers/Api/LecturerController.php @@ -3,12 +3,19 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreLecturerRequest; +use App\Http\Requests\Api\UpdateLecturerRequest; +use App\Http\Resources\LecturerResource; use App\Models\Lecturer; -use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Gate; +use Symfony\Component\HttpFoundation\Response; #[Group(name: 'Referenten', weight: 4)] class LecturerController extends Controller @@ -48,38 +55,63 @@ class LecturerController extends Controller } /** - * Store a newly created resource in storage. + * Referent anlegen + * + * Erlaubt einem authentifizierten Nutzer, einen Referenten programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch gesetzt. */ - #[ExcludeRouteFromDocs] - public function store(Request $request) + #[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function store(StoreLecturerRequest $request): JsonResponse { - // + $lecturer = Lecturer::create($request->validated()); + + return LecturerResource::make($lecturer->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } /** - * Display the specified resource. + * Referent aktualisieren + * + * Aktualisiert einen Referenten; nur fuer den Ersteller oder einen Super-Admin. */ - #[ExcludeRouteFromDocs] - public function show(Lecturer $lecturer) + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf den Referenten aendern.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function update(UpdateLecturerRequest $request, Lecturer $lecturer): LecturerResource { - // + $lecturer->update($request->validated()); + + return LecturerResource::make($lecturer->fresh()); } /** - * Update the specified resource in storage. + * Eigene Referenten auflisten + * + * Liefert alle vom authentifizierten Nutzer erstellten Referenten, alphabetisch sortiert. */ - #[ExcludeRouteFromDocs] - public function update(Request $request, Lecturer $lecturer) + public function mine(Request $request): AnonymousResourceCollection { - // + Gate::authorize('viewAny', Lecturer::class); + + $lecturers = Lecturer::query() + ->where('created_by', $request->user()->id) + ->orderBy('name') + ->get(); + + return LecturerResource::collection($lecturers); } /** - * Remove the specified resource from storage. + * Eigenen Referenten anzeigen + * + * Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Referenten. */ - #[ExcludeRouteFromDocs] - public function destroy(Lecturer $lecturer) + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf den Referenten sehen.')] + public function mineShow(Lecturer $lecturer): LecturerResource { - // + Gate::authorize('view', $lecturer); + + return LecturerResource::make($lecturer); } } diff --git a/app/Http/Controllers/Api/MeetupController.php b/app/Http/Controllers/Api/MeetupController.php index f30b700..70e51b9 100644 --- a/app/Http/Controllers/Api/MeetupController.php +++ b/app/Http/Controllers/Api/MeetupController.php @@ -3,13 +3,19 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreMeetupRequest; +use App\Http\Requests\Api\UpdateMeetupRequest; +use App\Http\Resources\MeetupResource; use App\Models\Meetup; use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; use Dedoc\Scramble\Attributes\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Gate; #[Group(name: 'Meetups', weight: 3)] class MeetupController extends Controller @@ -63,27 +69,64 @@ class MeetupController extends Controller }); } - #[ExcludeRouteFromDocs] - public function store(Request $request) + /** + * Meetup anlegen + * + * Erlaubt einem authentifizierten Nutzer, ein Meetup programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch gesetzt. + */ + #[Response(status: 401, description: 'Nicht authentifiziert.')] + #[Response(status: 422, description: 'Validierungsfehler.')] + public function store(StoreMeetupRequest $request): JsonResponse { - // + $meetup = Meetup::create($request->validated()); + + return MeetupResource::make($meetup->fresh()) + ->response() + ->setStatusCode(\Symfony\Component\HttpFoundation\Response::HTTP_CREATED); } - #[ExcludeRouteFromDocs] - public function show(Meetup $meetup) + /** + * Meetup aktualisieren + * + * Aktualisiert ein Meetup; nur fuer den Ersteller oder einen Super-Admin. + */ + #[Response(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Meetup aendern.')] + #[Response(status: 422, description: 'Validierungsfehler.')] + public function update(UpdateMeetupRequest $request, Meetup $meetup): MeetupResource { - // + $meetup->update($request->validated()); + + return MeetupResource::make($meetup->fresh()); } - #[ExcludeRouteFromDocs] - public function update(Request $request, Meetup $meetup) + /** + * Eigene Meetups auflisten + * + * Liefert alle vom authentifizierten Nutzer erstellten Meetups, alphabetisch sortiert. + */ + public function mine(Request $request): AnonymousResourceCollection { - // + Gate::authorize('viewAny', Meetup::class); + + $meetups = Meetup::query() + ->where('created_by', $request->user()->id) + ->orderBy('name') + ->get(); + + return MeetupResource::collection($meetups); } - #[ExcludeRouteFromDocs] - public function destroy(Meetup $meetup) + /** + * Eigenes Meetup anzeigen + * + * Zeigt ein einzelnes, vom authentifizierten Nutzer erstelltes Meetup. + */ + #[Response(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Meetup sehen.')] + public function mineShow(Meetup $meetup): MeetupResource { - // + Gate::authorize('view', $meetup); + + return MeetupResource::make($meetup); } } diff --git a/app/Http/Controllers/Api/MeetupEventController.php b/app/Http/Controllers/Api/MeetupEventController.php index 6384060..b4c88a1 100644 --- a/app/Http/Controllers/Api/MeetupEventController.php +++ b/app/Http/Controllers/Api/MeetupEventController.php @@ -3,11 +3,20 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\StoreMeetupEventRequest; +use App\Http\Requests\Api\UpdateMeetupEventRequest; +use App\Http\Resources\MeetupEventResource; use App\Models\MeetupEvent; use Carbon\Carbon; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\PathParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; +use Symfony\Component\HttpFoundation\Response; #[Group(name: 'Meetups', weight: 3)] class MeetupEventController extends Controller @@ -66,4 +75,65 @@ class MeetupEventController extends Controller ], ); } + + /** + * Meetup-Event anlegen + * + * Erlaubt einem authentifizierten Nutzer, ein Meetup-Event programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch gesetzt. + */ + #[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function store(StoreMeetupEventRequest $request): JsonResponse + { + $meetupEvent = MeetupEvent::create($request->validated()); + + return MeetupEventResource::make($meetupEvent->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); + } + + /** + * Meetup-Event aktualisieren + * + * Aktualisiert ein Meetup-Event; nur fuer den Ersteller oder einen Super-Admin. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Meetup-Event aendern.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function update(UpdateMeetupEventRequest $request, MeetupEvent $meetupEvent): MeetupEventResource + { + $meetupEvent->update($request->validated()); + + return MeetupEventResource::make($meetupEvent->fresh()); + } + + /** + * Eigene Meetup-Events auflisten + * + * Liefert alle vom authentifizierten Nutzer erstellten Meetup-Events, nach Startzeit absteigend sortiert. + */ + public function mine(Request $request): AnonymousResourceCollection + { + Gate::authorize('viewAny', MeetupEvent::class); + + $meetupEvents = MeetupEvent::query() + ->where('created_by', $request->user()->id) + ->orderByDesc('start') + ->get(); + + return MeetupEventResource::collection($meetupEvents); + } + + /** + * Eigenes Meetup-Event anzeigen + * + * Zeigt ein einzelnes, vom authentifizierten Nutzer erstelltes Meetup-Event. + */ + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf das Meetup-Event sehen.')] + public function mineShow(MeetupEvent $meetupEvent): MeetupEventResource + { + Gate::authorize('view', $meetupEvent); + + return MeetupEventResource::make($meetupEvent); + } } diff --git a/app/Http/Controllers/Api/VenueController.php b/app/Http/Controllers/Api/VenueController.php index decef41..95ce05c 100644 --- a/app/Http/Controllers/Api/VenueController.php +++ b/app/Http/Controllers/Api/VenueController.php @@ -3,13 +3,19 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\Lecturer; +use App\Http\Requests\Api\StoreVenueRequest; +use App\Http\Requests\Api\UpdateVenueRequest; +use App\Http\Resources\VenueResource; use App\Models\Venue; -use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Dedoc\Scramble\Attributes\Group; use Dedoc\Scramble\Attributes\QueryParameter; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\Gate; +use Symfony\Component\HttpFoundation\Response; #[Group(name: 'Stammdaten', weight: 5)] class VenueController extends Controller @@ -50,38 +56,63 @@ class VenueController extends Controller } /** - * Store a newly created resource in storage. + * Veranstaltungsort anlegen + * + * Erlaubt einem authentifizierten Nutzer, einen Veranstaltungsort programmatisch anzulegen. + * Der Ersteller (created_by) wird automatisch gesetzt. */ - #[ExcludeRouteFromDocs] - public function store(Request $request) + #[ResponseAttribute(status: 401, description: 'Nicht authentifiziert.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function store(StoreVenueRequest $request): JsonResponse { - // + $venue = Venue::create($request->validated()); + + return VenueResource::make($venue->fresh()) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } /** - * Display the specified resource. + * Veranstaltungsort aktualisieren + * + * Aktualisiert einen Veranstaltungsort; nur fuer den Ersteller oder einen Super-Admin. */ - #[ExcludeRouteFromDocs] - public function show(Lecturer $lecturer) + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf den Veranstaltungsort aendern.')] + #[ResponseAttribute(status: 422, description: 'Validierungsfehler.')] + public function update(UpdateVenueRequest $request, Venue $venue): VenueResource { - // + $venue->update($request->validated()); + + return VenueResource::make($venue->fresh()); } /** - * Update the specified resource in storage. + * Eigene Veranstaltungsorte auflisten + * + * Liefert alle vom authentifizierten Nutzer erstellten Veranstaltungsorte, alphabetisch sortiert. */ - #[ExcludeRouteFromDocs] - public function update(Request $request, Lecturer $lecturer) + public function mine(Request $request): AnonymousResourceCollection { - // + Gate::authorize('viewAny', Venue::class); + + $venues = Venue::query() + ->where('created_by', $request->user()->id) + ->orderBy('name') + ->get(); + + return VenueResource::collection($venues); } /** - * Remove the specified resource from storage. + * Eigenen Veranstaltungsort anzeigen + * + * Zeigt einen einzelnen, vom authentifizierten Nutzer erstellten Veranstaltungsort. */ - #[ExcludeRouteFromDocs] - public function destroy(Lecturer $lecturer) + #[ResponseAttribute(status: 403, description: 'Nur der Ersteller oder ein Super-Admin darf den Veranstaltungsort sehen.')] + public function mineShow(Venue $venue): VenueResource { - // + Gate::authorize('view', $venue); + + return VenueResource::make($venue); } } diff --git a/app/Http/Requests/Api/StoreCityRequest.php b/app/Http/Requests/Api/StoreCityRequest.php new file mode 100644 index 0000000..8b927c9 --- /dev/null +++ b/app/Http/Requests/Api/StoreCityRequest.php @@ -0,0 +1,38 @@ +user()->can('create', City::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'country_id' => ['required', 'integer', 'exists:countries,id'], + 'name' => ['required', 'string', 'max:255'], + 'longitude' => ['required', 'numeric'], + 'latitude' => ['required', 'numeric'], + 'population' => ['nullable', 'integer'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'country_id.exists' => 'Das angegebene Land existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/StoreLecturerRequest.php b/app/Http/Requests/Api/StoreLecturerRequest.php new file mode 100644 index 0000000..6c434f7 --- /dev/null +++ b/app/Http/Requests/Api/StoreLecturerRequest.php @@ -0,0 +1,46 @@ +user()->can('create', Lecturer::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'subtitle' => ['nullable', 'string'], + 'intro' => ['nullable', 'string'], + 'description' => ['nullable', 'string'], + 'active' => ['boolean'], + 'website' => ['nullable', 'url', 'max:255'], + 'twitter_username' => ['nullable', 'string', 'max:255'], + 'nostr' => ['nullable', 'string', 'max:255'], + 'lightning_address' => ['nullable', 'string', 'max:255'], + 'lnurl' => ['nullable', 'string'], + 'node_id' => ['nullable', 'string', 'max:255'], + 'paynym' => ['nullable', 'string'], + 'team_id' => ['nullable', 'integer', 'exists:teams,id'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'team_id.exists' => 'Das angegebene Team existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/StoreMeetupEventRequest.php b/app/Http/Requests/Api/StoreMeetupEventRequest.php new file mode 100644 index 0000000..8faa6f7 --- /dev/null +++ b/app/Http/Requests/Api/StoreMeetupEventRequest.php @@ -0,0 +1,45 @@ +user()->can('create', MeetupEvent::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'meetup_id' => ['required', 'integer', 'exists:meetups,id'], + 'start' => ['required', 'date'], + 'location' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'link' => ['nullable', 'url', 'max:255'], + 'recurrence_type' => ['nullable', Rule::enum(RecurrenceType::class)], + 'recurrence_day_of_week' => ['nullable', 'string', 'max:255'], + 'recurrence_day_position' => ['nullable', 'string', 'max:255'], + 'recurrence_interval' => ['nullable', 'integer'], + 'recurrence_end_date' => ['nullable', 'date', 'after_or_equal:start'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'meetup_id.exists' => 'Das angegebene Meetup existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/StoreMeetupRequest.php b/app/Http/Requests/Api/StoreMeetupRequest.php new file mode 100644 index 0000000..b68f715 --- /dev/null +++ b/app/Http/Requests/Api/StoreMeetupRequest.php @@ -0,0 +1,46 @@ +user()->can('create', Meetup::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'city_id' => ['required', 'integer', 'exists:cities,id'], + 'intro' => ['nullable', 'string'], + 'telegram_link' => ['nullable', 'url', 'max:255'], + 'webpage' => ['nullable', 'url', 'max:255'], + 'twitter_username' => ['nullable', 'string', 'max:255'], + 'matrix_group' => ['nullable', 'string', 'max:255'], + 'nostr' => ['nullable', 'string'], + 'simplex' => ['nullable', 'string'], + 'signal' => ['nullable', 'string', 'max:255'], + 'community' => ['nullable', 'string', 'max:255'], + 'visible_on_map' => ['boolean'], + 'is_active' => ['boolean'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'city_id.exists' => 'Die angegebene Stadt existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/StoreVenueRequest.php b/app/Http/Requests/Api/StoreVenueRequest.php new file mode 100644 index 0000000..830d031 --- /dev/null +++ b/app/Http/Requests/Api/StoreVenueRequest.php @@ -0,0 +1,36 @@ +user()->can('create', Venue::class); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'city_id' => ['required', 'integer', 'exists:cities,id'], + 'name' => ['required', 'string', 'max:255'], + 'street' => ['required', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'city_id.exists' => 'Die angegebene Stadt existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateCityRequest.php b/app/Http/Requests/Api/UpdateCityRequest.php new file mode 100644 index 0000000..b3d2889 --- /dev/null +++ b/app/Http/Requests/Api/UpdateCityRequest.php @@ -0,0 +1,37 @@ +user()->can('update', $this->route('city')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'country_id' => ['sometimes', 'required', 'integer', 'exists:countries,id'], + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'longitude' => ['sometimes', 'required', 'numeric'], + 'latitude' => ['sometimes', 'required', 'numeric'], + 'population' => ['sometimes', 'nullable', 'integer'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'country_id.exists' => 'Das angegebene Land existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateLecturerRequest.php b/app/Http/Requests/Api/UpdateLecturerRequest.php new file mode 100644 index 0000000..c5b405d --- /dev/null +++ b/app/Http/Requests/Api/UpdateLecturerRequest.php @@ -0,0 +1,45 @@ +user()->can('update', $this->route('lecturer')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'subtitle' => ['sometimes', 'nullable', 'string'], + 'intro' => ['sometimes', 'nullable', 'string'], + 'description' => ['sometimes', 'nullable', 'string'], + 'active' => ['sometimes', 'boolean'], + 'website' => ['sometimes', 'nullable', 'url', 'max:255'], + 'twitter_username' => ['sometimes', 'nullable', 'string', 'max:255'], + 'nostr' => ['sometimes', 'nullable', 'string', 'max:255'], + 'lightning_address' => ['sometimes', 'nullable', 'string', 'max:255'], + 'lnurl' => ['sometimes', 'nullable', 'string'], + 'node_id' => ['sometimes', 'nullable', 'string', 'max:255'], + 'paynym' => ['sometimes', 'nullable', 'string'], + 'team_id' => ['sometimes', 'nullable', 'integer', 'exists:teams,id'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'team_id.exists' => 'Das angegebene Team existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateMeetupEventRequest.php b/app/Http/Requests/Api/UpdateMeetupEventRequest.php new file mode 100644 index 0000000..7133fa0 --- /dev/null +++ b/app/Http/Requests/Api/UpdateMeetupEventRequest.php @@ -0,0 +1,44 @@ +user()->can('update', $this->route('meetupEvent')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'meetup_id' => ['sometimes', 'required', 'integer', 'exists:meetups,id'], + 'start' => ['sometimes', 'required', 'date'], + 'location' => ['sometimes', 'nullable', 'string', 'max:255'], + 'description' => ['sometimes', 'nullable', 'string'], + 'link' => ['sometimes', 'nullable', 'url', 'max:255'], + 'recurrence_type' => ['sometimes', 'nullable', Rule::enum(RecurrenceType::class)], + 'recurrence_day_of_week' => ['sometimes', 'nullable', 'string', 'max:255'], + 'recurrence_day_position' => ['sometimes', 'nullable', 'string', 'max:255'], + 'recurrence_interval' => ['sometimes', 'nullable', 'integer'], + 'recurrence_end_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:start'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'meetup_id.exists' => 'Das angegebene Meetup existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateMeetupRequest.php b/app/Http/Requests/Api/UpdateMeetupRequest.php new file mode 100644 index 0000000..51593b6 --- /dev/null +++ b/app/Http/Requests/Api/UpdateMeetupRequest.php @@ -0,0 +1,45 @@ +user()->can('update', $this->route('meetup')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'city_id' => ['sometimes', 'required', 'integer', 'exists:cities,id'], + 'intro' => ['sometimes', 'nullable', 'string'], + 'telegram_link' => ['sometimes', 'nullable', 'url', 'max:255'], + 'webpage' => ['sometimes', 'nullable', 'url', 'max:255'], + 'twitter_username' => ['sometimes', 'nullable', 'string', 'max:255'], + 'matrix_group' => ['sometimes', 'nullable', 'string', 'max:255'], + 'nostr' => ['sometimes', 'nullable', 'string'], + 'simplex' => ['sometimes', 'nullable', 'string'], + 'signal' => ['sometimes', 'nullable', 'string', 'max:255'], + 'community' => ['sometimes', 'nullable', 'string', 'max:255'], + 'visible_on_map' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'city_id.exists' => 'Die angegebene Stadt existiert nicht.', + ]; + } +} diff --git a/app/Http/Requests/Api/UpdateVenueRequest.php b/app/Http/Requests/Api/UpdateVenueRequest.php new file mode 100644 index 0000000..3bfa2cb --- /dev/null +++ b/app/Http/Requests/Api/UpdateVenueRequest.php @@ -0,0 +1,35 @@ +user()->can('update', $this->route('venue')); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'city_id' => ['sometimes', 'required', 'integer', 'exists:cities,id'], + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'street' => ['sometimes', 'required', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'city_id.exists' => 'Die angegebene Stadt existiert nicht.', + ]; + } +} diff --git a/app/Http/Resources/CityResource.php b/app/Http/Resources/CityResource.php new file mode 100644 index 0000000..faa1c26 --- /dev/null +++ b/app/Http/Resources/CityResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'country_id' => $this->country_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'longitude' => $this->longitude, + 'latitude' => $this->latitude, + 'population' => $this->population, + '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 new file mode 100644 index 0000000..ecfbd9b --- /dev/null +++ b/app/Http/Resources/LecturerResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'subtitle' => $this->subtitle, + 'intro' => $this->intro, + 'description' => $this->description, + 'active' => $this->active, + 'website' => $this->website, + 'twitter_username' => $this->twitter_username, + 'nostr' => $this->nostr, + 'lightning_address' => $this->lightning_address, + 'lnurl' => $this->lnurl, + 'node_id' => $this->node_id, + 'paynym' => $this->paynym, + 'team_id' => $this->team_id, + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/MeetupEventResource.php b/app/Http/Resources/MeetupEventResource.php new file mode 100644 index 0000000..612ec87 --- /dev/null +++ b/app/Http/Resources/MeetupEventResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'meetup_id' => $this->meetup_id, + 'start' => $this->start, + 'location' => $this->location, + 'description' => $this->description, + 'link' => $this->link, + 'recurrence_type' => $this->recurrence_type, + 'recurrence_day_of_week' => $this->recurrence_day_of_week, + 'recurrence_day_position' => $this->recurrence_day_position, + 'recurrence_interval' => $this->recurrence_interval, + 'recurrence_end_date' => $this->recurrence_end_date, + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/MeetupResource.php b/app/Http/Resources/MeetupResource.php new file mode 100644 index 0000000..d9d10b7 --- /dev/null +++ b/app/Http/Resources/MeetupResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'city_id' => $this->city_id, + 'intro' => $this->intro, + 'telegram_link' => $this->telegram_link, + 'webpage' => $this->webpage, + 'twitter_username' => $this->twitter_username, + 'matrix_group' => $this->matrix_group, + 'nostr' => $this->nostr, + 'simplex' => $this->simplex, + 'signal' => $this->signal, + 'community' => $this->community, + 'visible_on_map' => $this->visible_on_map, + 'is_active' => $this->is_active, + 'last_event_at' => $this->last_event_at, + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/VenueResource.php b/app/Http/Resources/VenueResource.php new file mode 100644 index 0000000..6dcf44a --- /dev/null +++ b/app/Http/Resources/VenueResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'city_id' => $this->city_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'street' => $this->street, + 'created_by' => $this->created_by, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Policies/CityPolicy.php b/app/Policies/CityPolicy.php new file mode 100644 index 0000000..a6ac78e --- /dev/null +++ b/app/Policies/CityPolicy.php @@ -0,0 +1,32 @@ +owns($user, $city); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, City $city): bool + { + return $this->owns($user, $city); + } +} diff --git a/app/Policies/Concerns/ChecksCreatorOwnership.php b/app/Policies/Concerns/ChecksCreatorOwnership.php new file mode 100644 index 0000000..2ef97e1 --- /dev/null +++ b/app/Policies/Concerns/ChecksCreatorOwnership.php @@ -0,0 +1,17 @@ +created_by === $user->id || $user->hasRole('super-admin'); + } +} diff --git a/app/Policies/LecturerPolicy.php b/app/Policies/LecturerPolicy.php new file mode 100644 index 0000000..04575da --- /dev/null +++ b/app/Policies/LecturerPolicy.php @@ -0,0 +1,32 @@ +owns($user, $lecturer); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Lecturer $lecturer): bool + { + return $this->owns($user, $lecturer); + } +} diff --git a/app/Policies/MeetupEventPolicy.php b/app/Policies/MeetupEventPolicy.php new file mode 100644 index 0000000..21989a2 --- /dev/null +++ b/app/Policies/MeetupEventPolicy.php @@ -0,0 +1,32 @@ +owns($user, $meetupEvent); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, MeetupEvent $meetupEvent): bool + { + return $this->owns($user, $meetupEvent); + } +} diff --git a/app/Policies/MeetupPolicy.php b/app/Policies/MeetupPolicy.php new file mode 100644 index 0000000..9d07a0a --- /dev/null +++ b/app/Policies/MeetupPolicy.php @@ -0,0 +1,32 @@ +owns($user, $meetup); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Meetup $meetup): bool + { + return $this->owns($user, $meetup); + } +} diff --git a/app/Policies/VenuePolicy.php b/app/Policies/VenuePolicy.php new file mode 100644 index 0000000..b4aa91a --- /dev/null +++ b/app/Policies/VenuePolicy.php @@ -0,0 +1,32 @@ +owns($user, $venue); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Venue $venue): bool + { + return $this->owns($user, $venue); + } +} diff --git a/config/scramble.php b/config/scramble.php index 8be79ea..455e8fe 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -43,7 +43,7 @@ return [ */ 'description' => <<<'MARKDOWN' Willkommen bei der **Einundzwanzig API** – der öffentlichen Schnittstelle der - [Einundzwanzig](https://einundzwanzig.space) Bitcoin-Community-Plattform. + [Einundzwanzig](https://portal.einundzwanzig.space) Bitcoin-Community-Plattform. Über diese API erreichst du die Daten der dezentralen deutschsprachigen Bitcoin-Bewegung: Meetups und ihre Termine, Kurse und Kurs-Events, Referenten, Veranstaltungsorte sowie die @@ -117,7 +117,7 @@ return [ * ``` */ 'servers' => [ - 'Production' => 'https://einundzwanzig.space/api', + 'Production' => 'https://portal.einundzwanzig.space/api', 'Local' => 'api', ], diff --git a/routes/api.php b/routes/api.php index a25c74f..4aba8f0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,14 +17,14 @@ use Illuminate\Support\Facades\Route; Route::middleware(['throttle:60,1']) ->as('api.') ->group(function () { - Route::resource('countries', CountryController::class); + Route::resource('countries', CountryController::class)->only(['index']); Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical'); - Route::resource('meetup', MeetupController::class); - Route::resource('lecturers', LecturerController::class); + Route::resource('meetup', MeetupController::class)->only(['index']); + Route::resource('lecturers', LecturerController::class)->only(['index']); Route::resource('courses', CourseController::class) ->only(['index', 'show']); - Route::resource('cities', CityController::class); - Route::resource('venues', VenueController::class); + Route::resource('cities', CityController::class)->only(['index']); + Route::resource('venues', VenueController::class)->only(['index']); Route::get('nostrplebs', NostrPlebController::class); Route::get('meetups', MeetupMapController::class); Route::get('meetup-events/{date?}', MeetupEventController::class); @@ -50,6 +50,31 @@ Route::middleware('auth:sanctum') ->name('course-events.store'); Route::patch('course-events/{courseEvent}', [CourseEventController::class, 'update']) ->name('course-events.update'); + + Route::post('lecturers', [LecturerController::class, 'store'])->name('lecturers.store'); + Route::patch('lecturers/{lecturer}', [LecturerController::class, 'update'])->name('lecturers.update'); + Route::get('my-lecturers', [LecturerController::class, 'mine'])->name('lecturers.mine'); + Route::get('my-lecturers/{lecturer}', [LecturerController::class, 'mineShow'])->name('lecturers.mine.show'); + + Route::post('venues', [VenueController::class, 'store'])->name('venues.store'); + Route::patch('venues/{venue}', [VenueController::class, 'update'])->name('venues.update'); + Route::get('my-venues', [VenueController::class, 'mine'])->name('venues.mine'); + Route::get('my-venues/{venue}', [VenueController::class, 'mineShow'])->name('venues.mine.show'); + + Route::post('cities', [CityController::class, 'store'])->name('cities.store'); + Route::patch('cities/{city}', [CityController::class, 'update'])->name('cities.update'); + Route::get('my-cities', [CityController::class, 'mine'])->name('cities.mine'); + Route::get('my-cities/{city}', [CityController::class, 'mineShow'])->name('cities.mine.show'); + + Route::post('meetup', [MeetupController::class, 'store'])->name('meetup.store'); + Route::patch('meetup/{meetup}', [MeetupController::class, 'update'])->name('meetup.update'); + Route::get('my-meetups', [MeetupController::class, 'mine'])->name('meetup.mine'); + Route::get('my-meetups/{meetup}', [MeetupController::class, 'mineShow'])->name('meetup.mine.show'); + + Route::post('meetup-events', [MeetupEventController::class, 'store'])->name('meetup-events.store'); + Route::patch('meetup-events/{meetupEvent}', [MeetupEventController::class, 'update'])->name('meetup-events.update'); + Route::get('my-meetup-events', [MeetupEventController::class, 'mine'])->name('meetup-events.mine'); + Route::get('my-meetup-events/{meetupEvent}', [MeetupEventController::class, 'mineShow'])->name('meetup-events.mine.show'); }); Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback']) diff --git a/tests/Feature/Api/CityWriteApiTest.php b/tests/Feature/Api/CityWriteApiTest.php new file mode 100644 index 0000000..ba4450b --- /dev/null +++ b/tests/Feature/Api/CityWriteApiTest.php @@ -0,0 +1,93 @@ +postJson('/api/cities', [ + 'name' => 'Ansbach', + 'country_id' => Country::factory()->create()->id, + ]); + + $response->assertUnauthorized(); +}); + +it('lets an authenticated user create', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $response = $this->postJson('/api/cities', [ + 'name' => 'Ansbach', + 'country_id' => Country::factory()->create()->id, + 'longitude' => 10.5806, + 'latitude' => 49.3034, + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('cities', [ + 'name' => 'Ansbach', + 'created_by' => $user->id, + ]); +}); + +it('fails validation', function () { + Sanctum::actingAs(User::factory()->create()); + + $response = $this->postJson('/api/cities', []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'country_id']); +}); + +it('lets the owner update', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $model = City::factory()->create(['created_by' => $user->id]); + + $response = $this->patchJson("/api/cities/{$model->id}", [ + 'name' => 'Nürnberg', + ]); + + $response->assertSuccessful() + ->assertJsonPath('data.name', 'Nürnberg'); +}); + +it('forbids updating someone elses', function () { + $owner = User::factory()->create(); + $model = City::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $response = $this->patchJson("/api/cities/{$model->id}", [ + 'name' => 'Nürnberg', + ]); + + $response->assertForbidden(); +}); + +it('returns only own in mine index', function () { + Sanctum::actingAs($user = User::factory()->create()); + + City::factory()->create(['created_by' => $user->id]); + City::factory()->create(['created_by' => $user->id]); + City::factory()->create(['created_by' => User::factory()->create()->id]); + + $response = $this->getJson('/api/my-cities'); + + $response->assertSuccessful(); + + expect($response->json('data'))->toHaveCount(2); +}); + +it('forbids viewing someone elses in mine show', function () { + $owner = User::factory()->create(); + $model = City::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $response = $this->getJson("/api/my-cities/{$model->id}"); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Api/LecturerWriteApiTest.php b/tests/Feature/Api/LecturerWriteApiTest.php new file mode 100644 index 0000000..6a7dc08 --- /dev/null +++ b/tests/Feature/Api/LecturerWriteApiTest.php @@ -0,0 +1,82 @@ +postJson('/api/lecturers', [ + 'name' => 'Saifedean Ammous', + ])->assertUnauthorized(); +}); + +it('lets an authenticated user create', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $this->postJson('/api/lecturers', [ + 'name' => 'Saifedean Ammous', + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Saifedean Ammous'); + + $this->assertDatabaseHas('lecturers', [ + 'name' => 'Saifedean Ammous', + 'created_by' => $user->id, + ]); +}); + +it('fails validation', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/lecturers', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name']); +}); + +it('lets the owner update', function () { + Sanctum::actingAs($user = User::factory()->create()); + $lecturer = Lecturer::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/lecturers/'.$lecturer->id, [ + 'name' => 'Knut Svanholm', + ]) + ->assertSuccessful() + ->assertJsonPath('data.name', 'Knut Svanholm'); +}); + +it('forbids updating someone elses', function () { + $owner = User::factory()->create(); + $lecturer = Lecturer::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->patchJson('/api/lecturers/'.$lecturer->id, [ + 'name' => 'Knut Svanholm', + ])->assertForbidden(); +}); + +it('returns only own in mine index', function () { + Sanctum::actingAs($user = User::factory()->create()); + $other = User::factory()->create(); + + Lecturer::factory()->count(2)->create(['created_by' => $user->id]); + Lecturer::factory()->create(['created_by' => $other->id]); + + $response = $this->getJson('/api/my-lecturers'); + + $response->assertSuccessful(); + expect($response->json('data'))->toHaveCount(2); + collect($response->json('data'))->each( + fn ($lecturer) => expect($lecturer['created_by'])->toBe($user->id) + ); +}); + +it('forbids viewing someone elses in mine show', function () { + $owner = User::factory()->create(); + $lecturer = Lecturer::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/my-lecturers/'.$lecturer->id) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/MeetupEventWriteApiTest.php b/tests/Feature/Api/MeetupEventWriteApiTest.php new file mode 100644 index 0000000..e09ea6e --- /dev/null +++ b/tests/Feature/Api/MeetupEventWriteApiTest.php @@ -0,0 +1,88 @@ +postJson('/api/meetup-events', []); + + $response->assertUnauthorized(); +}); + +it('lets an authenticated user create', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $response = $this->postJson('/api/meetup-events', [ + 'meetup_id' => Meetup::factory()->create()->id, + 'start' => '2026-08-01 18:00:00', + 'location' => 'Marktplatz', + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('meetup_events', [ + 'location' => 'Marktplatz', + 'created_by' => $user->id, + ]); +}); + +it('fails validation', function () { + Sanctum::actingAs(User::factory()->create()); + + $response = $this->postJson('/api/meetup-events', []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['meetup_id', 'start']); +}); + +it('lets the owner update', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $model = MeetupEvent::factory()->create(['created_by' => $user->id]); + + $response = $this->patchJson("/api/meetup-events/{$model->id}", [ + 'location' => 'Rathaus', + ]); + + $response->assertSuccessful() + ->assertJsonPath('data.location', 'Rathaus'); +}); + +it('forbids updating someone elses', function () { + $owner = User::factory()->create(); + $model = MeetupEvent::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $response = $this->patchJson("/api/meetup-events/{$model->id}", [ + 'location' => 'Rathaus', + ]); + + $response->assertForbidden(); +}); + +it('returns only own in mine index', function () { + Sanctum::actingAs($user = User::factory()->create()); + + MeetupEvent::factory()->count(2)->create(['created_by' => $user->id]); + MeetupEvent::factory()->create(['created_by' => User::factory()->create()->id]); + + $response = $this->getJson('/api/my-meetup-events'); + + $response->assertSuccessful(); + + expect($response->json('data'))->toHaveCount(2); +}); + +it('forbids viewing someone elses in mine show', function () { + $owner = User::factory()->create(); + $model = MeetupEvent::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $response = $this->getJson("/api/my-meetup-events/{$model->id}"); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Api/MeetupWriteApiTest.php b/tests/Feature/Api/MeetupWriteApiTest.php new file mode 100644 index 0000000..ef74f37 --- /dev/null +++ b/tests/Feature/Api/MeetupWriteApiTest.php @@ -0,0 +1,84 @@ +postJson('/api/meetup', [ + 'name' => 'Einundzwanzig Ansbach', + 'city_id' => City::factory()->create()->id, + ])->assertUnauthorized(); +}); + +it('lets an authenticated user create', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $this->postJson('/api/meetup', [ + 'name' => 'Einundzwanzig Ansbach', + 'city_id' => City::factory()->create()->id, + ]) + ->assertCreated() + ->assertJsonPath('data.name', 'Einundzwanzig Ansbach'); + + $this->assertDatabaseHas('meetups', [ + 'name' => 'Einundzwanzig Ansbach', + 'created_by' => $user->id, + ]); +}); + +it('fails validation', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/meetup', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'city_id']); +}); + +it('lets the owner update', function () { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/meetup/'.$meetup->id, [ + 'name' => 'Plan B Lugano', + ]) + ->assertSuccessful() + ->assertJsonPath('data.name', 'Plan B Lugano'); +}); + +it('forbids updating someone elses', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->patchJson('/api/meetup/'.$meetup->id, [ + 'name' => 'Plan B Lugano', + ])->assertForbidden(); +}); + +it('returns only own in mine index', function () { + Sanctum::actingAs($user = User::factory()->create()); + $other = User::factory()->create(); + + Meetup::factory()->count(2)->create(['created_by' => $user->id]); + Meetup::factory()->create(['created_by' => $other->id]); + + $response = $this->getJson('/api/my-meetups'); + + $response->assertSuccessful(); + expect($response->json('data'))->toHaveCount(2); + collect($response->json('data'))->each( + fn ($meetup) => expect($meetup['created_by'])->toBe($user->id) + ); +}); + +it('forbids viewing someone elses in mine show', function () { + $owner = User::factory()->create(); + $meetup = Meetup::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/my-meetups/'.$meetup->id)->assertForbidden(); +}); diff --git a/tests/Feature/Api/VenueWriteApiTest.php b/tests/Feature/Api/VenueWriteApiTest.php new file mode 100644 index 0000000..0300a87 --- /dev/null +++ b/tests/Feature/Api/VenueWriteApiTest.php @@ -0,0 +1,83 @@ +postJson('/api/venues', [ + 'name' => 'Bitcoin Hub', + 'city_id' => City::factory()->create()->id, + ])->assertUnauthorized(); +}); + +it('lets an authenticated user create', function () { + Sanctum::actingAs($user = User::factory()->create()); + + $this->postJson('/api/venues', [ + 'name' => 'Bitcoin Hub', + 'city_id' => City::factory()->create()->id, + 'street' => 'Satoshi Street 21', + ])->assertCreated(); + + $this->assertDatabaseHas('venues', [ + 'name' => 'Bitcoin Hub', + 'created_by' => $user->id, + ]); +}); + +it('fails validation', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/venues', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'city_id']); +}); + +it('lets the owner update', function () { + Sanctum::actingAs($user = User::factory()->create()); + $model = Venue::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/venues/'.$model->id, [ + 'name' => 'Orange Hub', + ]) + ->assertSuccessful() + ->assertJsonPath('data.name', 'Orange Hub'); +}); + +it('forbids updating someone elses', function () { + $owner = User::factory()->create(); + $model = Venue::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->patchJson('/api/venues/'.$model->id, [ + 'name' => 'Orange Hub', + ])->assertForbidden(); +}); + +it('returns only own in mine index', function () { + Sanctum::actingAs($user = User::factory()->create()); + $other = User::factory()->create(); + + Venue::factory()->count(2)->create(['created_by' => $user->id]); + Venue::factory()->create(['created_by' => $other->id]); + + $response = $this->getJson('/api/my-venues'); + + $response->assertSuccessful(); + expect($response->json('data'))->toHaveCount(2); + collect($response->json('data'))->each( + fn ($venue) => expect($venue['created_by'])->toBe($user->id) + ); +}); + +it('forbids viewing someone elses in mine show', function () { + $owner = User::factory()->create(); + $model = Venue::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->create()); + + $this->getJson('/api/my-venues/'.$model->id)->assertForbidden(); +});