diff --git a/app/Http/Controllers/Api/CourseController.php b/app/Http/Controllers/Api/CourseController.php index 3e0e081..6e43e23 100644 --- a/app/Http/Controllers/Api/CourseController.php +++ b/app/Http/Controllers/Api/CourseController.php @@ -6,7 +6,9 @@ use App\Http\Controllers\Controller; use App\Models\Course; use App\Models\Lecturer; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; class CourseController extends Controller { @@ -16,36 +18,50 @@ class CourseController extends Controller public function index(Request $request) { return Course::query() - ->select('id', 'name', ) - ->orderBy('name') - ->when($request->has('user_id'), - fn(Builder $query) => $query->where('created_by', $request->user_id)) - ->when( + ->select('id', 'name') + ->orderBy('name') + ->when($request->has('user_id'), + fn (Builder $query) => $query->where('created_by', $request->user_id)) + ->when( $request->search, fn (Builder $query) => $query ->where('name', 'ilike', "%{$request->search}%") ) - ->when( - $request->exists('selected'), - fn (Builder $query) => $query->whereIn('id', - $request->input('selected', [])), - fn (Builder $query) => $query->limit(10) - ) - ->get() - ->map(function (Course $course) { - $course->image = $course->getFirstMediaUrl('logo', - 'thumb'); + ->when( + $request->exists('selected'), + fn (Builder $query) => $query->whereIn('id', + $request->input('selected', [])), + fn (Builder $query) => $query->limit(10) + ) + ->get() + ->map(function (Course $course) { + $course->image = $course->getFirstMediaUrl('logo', + 'thumb'); - return $course; - }); + return $course; + }); } /** * Store a newly created resource in storage. + * + * Allows an authenticated lecturer to create a course programmatically + * (e.g. to sync courses from an external system). Validation mirrors the + * Livewire course create form; `created_by` is set by the model's creating hook. */ - public function store(Request $request) + public function store(Request $request): JsonResponse { - // + abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + + $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); } /** @@ -58,10 +74,25 @@ class CourseController extends Controller /** * Update the specified resource in storage. + * + * Authorized for the course owner (or a super-admin). */ - public function update(Request $request, Course $course) + public function update(Request $request, Course $course): JsonResponse { - // + abort_unless( + (int) $course->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), + Response::HTTP_FORBIDDEN + ); + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'lecturer_id' => ['sometimes', 'required', 'exists:lecturers,id'], + 'description' => ['sometimes', 'nullable', 'string'], + ]); + + $course->update($validated); + + return response()->json($course->fresh()); } /** diff --git a/app/Http/Controllers/Api/CourseEventController.php b/app/Http/Controllers/Api/CourseEventController.php new file mode 100644 index 0000000..7e8e038 --- /dev/null +++ b/app/Http/Controllers/Api/CourseEventController.php @@ -0,0 +1,84 @@ + + */ + public function index(Request $request): Collection + { + return CourseEvent::query() + ->with(['course:id,name', 'venue:id,name']) + ->where('created_by', $request->user()->id) + ->when( + $request->filled('course_id'), + fn (Builder $query) => $query->where('course_id', $request->integer('course_id')) + ) + ->orderByDesc('from') + ->get(); + } + + /** + * Store a newly created course event in storage. + * + * Allows an authenticated lecturer to create a dated course event + * programmatically. Validation mirrors the Livewire course event form; + * `created_by` is set by the model's creating hook. + */ + public function store(Request $request): JsonResponse + { + abort_unless((bool) $request->user()->is_lecturer, Response::HTTP_FORBIDDEN); + + $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); + } + + /** + * Update the specified course event in storage. + * + * Authorized for the course event owner (or a super-admin). + */ + public function update(Request $request, CourseEvent $courseEvent): JsonResponse + { + abort_unless( + (int) $courseEvent->created_by === $request->user()->id || $request->user()->hasRole('super-admin'), + Response::HTTP_FORBIDDEN + ); + + $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()); + } +} diff --git a/routes/api.php b/routes/api.php index 315ce4b..0b944c9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,11 +3,17 @@ use App\Http\Controllers\Api\CityController; use App\Http\Controllers\Api\CountryController; use App\Http\Controllers\Api\CourseController; +use App\Http\Controllers\Api\CourseEventController; use App\Http\Controllers\Api\HighscoreController; use App\Http\Controllers\Api\LecturerController; use App\Http\Controllers\Api\MeetupController; use App\Http\Controllers\Api\VenueController; +use App\Http\Controllers\LnurlAuthController; +use App\Models\LibraryItem; +use App\Models\Meetup; +use App\Models\MeetupEvent; use App\Models\User; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -18,7 +24,8 @@ Route::middleware(['throttle:60,1']) Route::get('meetup/ical', [MeetupController::class, 'ical'])->name('api.meetup.ical'); Route::resource('meetup', MeetupController::class); Route::resource('lecturers', LecturerController::class); - Route::resource('courses', CourseController::class); + Route::resource('courses', CourseController::class) + ->only(['index', 'show']); Route::resource('cities', CityController::class); Route::resource('venues', VenueController::class); Route::get('highscores', [HighscoreController::class, 'index'])->name('highscores.index'); @@ -46,7 +53,7 @@ Route::middleware(['throttle:60,1']) ->pluck('nostr'); }); Route::get('bindles', function () { - return \App\Models\LibraryItem::query() + return LibraryItem::query() ->where('type', 'bindle') ->with([ 'media', @@ -61,7 +68,7 @@ Route::middleware(['throttle:60,1']) ]); }); Route::get('meetups', function (Request $request) { - return \App\Models\Meetup::query() + return Meetup::query() ->where('visible_on_map', true) ->with([ 'meetupEvents', @@ -95,9 +102,9 @@ Route::middleware(['throttle:60,1']) }); Route::get('meetup-events/{date?}', function ($date = null) { if ($date) { - $date = \Carbon\Carbon::parse($date); + $date = Carbon::parse($date); } - $events = \App\Models\MeetupEvent::query() + $events = MeetupEvent::query() ->with([ 'meetup.city.country', 'meetup.media', @@ -139,7 +146,7 @@ Route::middleware(['throttle:60,1']) }); Route::get('btc-map-communities', function () { return response()->json( - \App\Models\Meetup::query() + Meetup::query() ->with([ 'media', 'city.country', @@ -184,8 +191,29 @@ Route::middleware(['throttle:60,1']) }); }); -Route::get('/lnurl-auth-callback', [\App\Http\Controllers\LnurlAuthController::class, 'callback']) +/* + * Authenticated write endpoints (Sanctum token auth). + * Lets a lecturer create/update their own courses and course events + * programmatically, e.g. to sync events from an external system. + */ +Route::middleware('auth:sanctum') + ->as('api.') + ->group(function () { + Route::post('courses', [CourseController::class, 'store']) + ->name('courses.store'); + Route::patch('courses/{course}', [CourseController::class, 'update']) + ->name('courses.update'); + + Route::get('course-events', [CourseEventController::class, 'index']) + ->name('course-events.index'); + Route::post('course-events', [CourseEventController::class, 'store']) + ->name('course-events.store'); + Route::patch('course-events/{courseEvent}', [CourseEventController::class, 'update']) + ->name('course-events.update'); + }); + +Route::get('/lnurl-auth-callback', [LnurlAuthController::class, 'callback']) ->name('auth.ln.callback'); -Route::post('/check-auth-error', [\App\Http\Controllers\LnurlAuthController::class, 'checkError']) +Route::post('/check-auth-error', [LnurlAuthController::class, 'checkError']) ->name('auth.check-error'); diff --git a/tests/Feature/Api/CourseEventApiTest.php b/tests/Feature/Api/CourseEventApiTest.php new file mode 100644 index 0000000..c50aa3c --- /dev/null +++ b/tests/Feature/Api/CourseEventApiTest.php @@ -0,0 +1,126 @@ +create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + ])->assertUnauthorized(); +}); + +it('forbids a non-lecturer from creating a course', function () { + Sanctum::actingAs(User::factory()->create(['is_lecturer' => false])); + $lecturer = Lecturer::factory()->create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + ])->assertForbidden(); +}); + +it('lets a lecturer create a course', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $lecturer = Lecturer::factory()->create(); + + $this->postJson('/api/courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'lecturer_id' => $lecturer->id, + 'description' => 'Hardware-Wallet selbst bauen.', + ]) + ->assertCreated() + ->assertJsonPath('name', 'Specter Shield Lite Workshop'); + + $this->assertDatabaseHas('courses', [ + 'name' => 'Specter Shield Lite Workshop', + 'created_by' => $user->id, + ]); +}); + +it('lets a lecturer create a course event', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $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://clavastack.com/produkt/specter-shield-lite-workshop', + ]) + ->assertCreated() + ->assertJsonPath('course_id', $course->id); + + $this->assertDatabaseHas('course_events', [ + 'course_id' => $course->id, + 'venue_id' => $venue->id, + 'created_by' => $user->id, + ]); +}); + +it('fails course event validation without required fields', function () { + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->postJson('/api/course-events', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['course_id', 'venue_id', 'from', 'to', 'link']); +}); + +it('returns only the authenticated user\'s own course events', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $other = User::factory()->lecturer()->create(); + + CourseEvent::factory()->count(2)->create(['created_by' => $user->id]); + CourseEvent::factory()->create(['created_by' => $other->id]); + + $response = $this->getJson('/api/course-events'); + + $response->assertSuccessful(); + expect($response->json())->toHaveCount(2); + collect($response->json())->each( + fn ($event) => expect($event['created_by'])->toBe($user->id) + ); +}); + +it('filters own course events by course_id', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + + $event = CourseEvent::factory()->create(['created_by' => $user->id]); + CourseEvent::factory()->create(['created_by' => $user->id]); + + $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); +}); + +it('lets the owner update their course event', function () { + Sanctum::actingAs($user = User::factory()->lecturer()->create()); + $event = CourseEvent::factory()->create(['created_by' => $user->id]); + + $this->patchJson('/api/course-events/'.$event->id, [ + 'link' => 'https://einundzwanzig.space/courses/updated', + ]) + ->assertSuccessful() + ->assertJsonPath('link', 'https://einundzwanzig.space/courses/updated'); +}); + +it('forbids updating a course event owned by someone else', function () { + $owner = User::factory()->lecturer()->create(); + $event = CourseEvent::factory()->create(['created_by' => $owner->id]); + + Sanctum::actingAs(User::factory()->lecturer()->create()); + + $this->patchJson('/api/course-events/'.$event->id, [ + 'link' => 'https://einundzwanzig.space/courses/hijacked', + ])->assertForbidden(); +});