From 6ce5b6e018f87c5988a1553c380daa5bc47c78f6 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 3 May 2026 23:17:04 +0200 Subject: [PATCH] feat(controllers): add macro/label import + global assignment + service override controllers and routes - MacroImportController + LabelImportController: POST endpoints accepting uploaded .bin files, delegating to MacrosImportService / LabelsImportService and returning import stats / warnings as JSON. Generic German 422 error if parser rejects the file. - MacroAssignmentController: index renders Settings Inertia page with assignments / macros / labels / collections / last-import metadata. store/update/destroy/reorder for global MacroAssignment rows. - ServiceMacroOverrideController: store snapshots all matching global MacroAssignments into service-specific rows when a service opts to override; destroy removes both override and service-specific assignments. storeAssignment / updateAssignment / destroyAssignment manage the per-service rows directly. - routes/web.php: 12 new named routes inside the auth middleware group; reorder route placed before {macroAssignment} parameter route to avoid capture conflict. - Tests: 19 new Pest tests across 4 feature files (54 assertions). Full suite 376 passed. --- .../Controllers/LabelImportController.php | 37 ++++++ .../Controllers/MacroAssignmentController.php | 86 ++++++++++++++ .../Controllers/MacroImportController.php | 41 +++++++ .../ServiceMacroOverrideController.php | 94 +++++++++++++++ routes/web.php | 34 ++++++ tests/Feature/LabelImportControllerTest.php | 33 ++++++ .../Feature/MacroAssignmentControllerTest.php | 108 ++++++++++++++++++ tests/Feature/MacroImportControllerTest.php | 41 +++++++ .../ServiceMacroOverrideControllerTest.php | 104 +++++++++++++++++ 9 files changed, 578 insertions(+) create mode 100644 app/Http/Controllers/LabelImportController.php create mode 100644 app/Http/Controllers/MacroAssignmentController.php create mode 100644 app/Http/Controllers/MacroImportController.php create mode 100644 app/Http/Controllers/ServiceMacroOverrideController.php create mode 100644 tests/Feature/LabelImportControllerTest.php create mode 100644 tests/Feature/MacroAssignmentControllerTest.php create mode 100644 tests/Feature/MacroImportControllerTest.php create mode 100644 tests/Feature/ServiceMacroOverrideControllerTest.php diff --git a/app/Http/Controllers/LabelImportController.php b/app/Http/Controllers/LabelImportController.php new file mode 100644 index 0000000..0cf0add --- /dev/null +++ b/app/Http/Controllers/LabelImportController.php @@ -0,0 +1,37 @@ +validate(['file' => ['required', 'file', 'max:5120']]); + + $file = $request->file('file'); + $tempPath = $file->getPathname(); + + try { + $result = $this->importService->import($tempPath, $file->getClientOriginalName()); + } catch (Throwable $e) { + return response()->json([ + 'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Labels-Datei ist.', + ], 422); + } + + return response()->json([ + 'new' => $result->newCount, + 'updated' => $result->updatedCount, + 'total' => $result->totalInFile, + ]); + } +} diff --git a/app/Http/Controllers/MacroAssignmentController.php b/app/Http/Controllers/MacroAssignmentController.php new file mode 100644 index 0000000..bb10547 --- /dev/null +++ b/app/Http/Controllers/MacroAssignmentController.php @@ -0,0 +1,86 @@ + MacroAssignment::with(['macro', 'label'])->orderBy('part_type')->orderBy('order')->get(), + 'macros' => Macro::with('collections')->orderBy('name')->get(), + 'labels' => Label::orderBy('name')->get(), + 'collections' => MacroCollection::orderBy('name')->get(), + 'last_macros_import' => [ + 'at' => Setting::get('macros_last_imported_at'), + 'filename' => Setting::get('macros_last_imported_filename'), + ], + 'last_labels_import' => [ + 'at' => Setting::get('labels_last_imported_at'), + 'filename' => Setting::get('labels_last_imported_filename'), + ], + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'], + 'macro_id' => ['required', 'integer', 'exists:macros,id'], + 'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'], + 'label_id' => ['nullable', 'integer', 'exists:labels,id'], + 'order' => ['integer', 'min:0'], + ]); + + $assignment = MacroAssignment::create($validated); + + return response()->json(['id' => $assignment->id, 'success' => true]); + } + + public function update(Request $request, MacroAssignment $macroAssignment): JsonResponse + { + $validated = $request->validate([ + 'part_type' => ['sometimes', 'in:information,moderation,sermon,song,agenda_item'], + 'macro_id' => ['sometimes', 'integer', 'exists:macros,id'], + 'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'], + 'label_id' => ['nullable', 'integer', 'exists:labels,id'], + 'order' => ['sometimes', 'integer', 'min:0'], + ]); + + $macroAssignment->update($validated); + + return response()->json(['success' => true]); + } + + public function destroy(MacroAssignment $macroAssignment): JsonResponse + { + $macroAssignment->delete(); + + return response()->json(['success' => true]); + } + + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'assignments' => ['required', 'array'], + 'assignments.*.id' => ['required', 'integer', 'exists:macro_assignments,id'], + 'assignments.*.order' => ['required', 'integer', 'min:0'], + ]); + + foreach ($validated['assignments'] as $item) { + MacroAssignment::where('id', $item['id'])->update(['order' => $item['order']]); + } + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/MacroImportController.php b/app/Http/Controllers/MacroImportController.php new file mode 100644 index 0000000..7b74bcd --- /dev/null +++ b/app/Http/Controllers/MacroImportController.php @@ -0,0 +1,41 @@ +validate(['file' => ['required', 'file', 'max:5120']]); + + $file = $request->file('file'); + $tempPath = $file->getPathname(); + + try { + $result = $this->importService->import($tempPath, $file->getClientOriginalName()); + } catch (Throwable $e) { + return response()->json([ + 'message' => 'Die Datei konnte nicht gelesen werden. Stelle sicher, dass es eine gültige ProPresenter Makro-Datei ist.', + ], 422); + } + + return response()->json([ + 'stats' => [ + 'new' => $result->new, + 'updated' => $result->updated, + 'disabled' => $result->disabled, + 're_enabled' => $result->reEnabled, + ], + 'warnings' => $result->warnings, + ]); + } +} diff --git a/app/Http/Controllers/ServiceMacroOverrideController.php b/app/Http/Controllers/ServiceMacroOverrideController.php new file mode 100644 index 0000000..442faa3 --- /dev/null +++ b/app/Http/Controllers/ServiceMacroOverrideController.php @@ -0,0 +1,94 @@ +validate([ + 'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'], + ]); + + ServiceMacroOverride::firstOrCreate([ + 'service_id' => $service->id, + 'part_type' => $validated['part_type'], + ]); + + $globals = MacroAssignment::where('part_type', $validated['part_type'])->orderBy('order')->get(); + foreach ($globals as $global) { + ServiceMacroAssignment::firstOrCreate([ + 'service_id' => $service->id, + 'part_type' => $validated['part_type'], + 'macro_id' => $global->macro_id, + 'position' => $global->position, + 'label_id' => $global->label_id, + 'order' => $global->order, + ]); + } + + return response()->json(['success' => true]); + } + + public function destroy(Service $service, Request $request): JsonResponse + { + $validated = $request->validate([ + 'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'], + ]); + + ServiceMacroOverride::where('service_id', $service->id) + ->where('part_type', $validated['part_type']) + ->delete(); + + ServiceMacroAssignment::where('service_id', $service->id) + ->where('part_type', $validated['part_type']) + ->delete(); + + return response()->json(['success' => true]); + } + + public function storeAssignment(Request $request, Service $service): JsonResponse + { + $validated = $request->validate([ + 'part_type' => ['required', 'in:information,moderation,sermon,song,agenda_item'], + 'macro_id' => ['required', 'integer', 'exists:macros,id'], + 'position' => ['required', 'in:all_slides,first_slide,last_slide,by_label'], + 'label_id' => ['nullable', 'integer', 'exists:labels,id'], + 'order' => ['integer', 'min:0'], + ]); + + $assignment = ServiceMacroAssignment::create([ + 'service_id' => $service->id, + ...$validated, + ]); + + return response()->json(['id' => $assignment->id, 'success' => true]); + } + + public function updateAssignment(Request $request, Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse + { + $validated = $request->validate([ + 'position' => ['sometimes', 'in:all_slides,first_slide,last_slide,by_label'], + 'label_id' => ['nullable', 'integer', 'exists:labels,id'], + 'order' => ['sometimes', 'integer', 'min:0'], + ]); + + $serviceMacroAssignment->update($validated); + + return response()->json(['success' => true]); + } + + public function destroyAssignment(Service $service, ServiceMacroAssignment $serviceMacroAssignment): JsonResponse + { + $serviceMacroAssignment->delete(); + + return response()->json(['success' => true]); + } +} diff --git a/routes/web.php b/routes/web.php index 0273032..2835bf7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,7 +2,11 @@ use App\Http\Controllers\ApiLogController; use App\Http\Controllers\AuthController; +use App\Http\Controllers\LabelImportController; +use App\Http\Controllers\MacroAssignmentController; +use App\Http\Controllers\MacroImportController; use App\Http\Controllers\ServiceController; +use App\Http\Controllers\ServiceMacroOverrideController; use App\Http\Controllers\SettingsController; use App\Http\Controllers\SongPdfController; use App\Http\Controllers\SyncController; @@ -90,4 +94,34 @@ Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder'); Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy'); Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date'); + + /* + |-------------------------------------------------------------------------- + | Makro- und Label-Import (ProPresenter) + |-------------------------------------------------------------------------- + */ + Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import'); + Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import'); + + /* + |-------------------------------------------------------------------------- + | Globale Makro-Zuweisungen + |-------------------------------------------------------------------------- + */ + Route::get('/settings/macro-assignments', [MacroAssignmentController::class, 'index'])->name('settings.macro-assignments.index'); + Route::post('/settings/macro-assignments/reorder', [MacroAssignmentController::class, 'reorder'])->name('settings.macro-assignments.reorder'); + Route::post('/settings/macro-assignments', [MacroAssignmentController::class, 'store'])->name('settings.macro-assignments.store'); + Route::patch('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'update'])->name('settings.macro-assignments.update'); + Route::delete('/settings/macro-assignments/{macroAssignment}', [MacroAssignmentController::class, 'destroy'])->name('settings.macro-assignments.destroy'); + + /* + |-------------------------------------------------------------------------- + | Service-spezifische Makro-Overrides + |-------------------------------------------------------------------------- + */ + Route::post('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'store'])->name('services.macro-overrides.store'); + Route::delete('/services/{service}/macro-overrides', [ServiceMacroOverrideController::class, 'destroy'])->name('services.macro-overrides.destroy'); + Route::post('/services/{service}/macro-assignments', [ServiceMacroOverrideController::class, 'storeAssignment'])->name('services.macro-assignments.store'); + Route::patch('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'updateAssignment'])->name('services.macro-assignments.update'); + Route::delete('/services/{service}/macro-assignments/{serviceMacroAssignment}', [ServiceMacroOverrideController::class, 'destroyAssignment'])->name('services.macro-assignments.destroy'); }); diff --git a/tests/Feature/LabelImportControllerTest.php b/tests/Feature/LabelImportControllerTest.php new file mode 100644 index 0000000..d72ddca --- /dev/null +++ b/tests/Feature/LabelImportControllerTest.php @@ -0,0 +1,33 @@ +post(route('settings.labels.import'), []); + $response->assertRedirect(route('login')); +}); + +test('label import returns json on valid file', function () { + $user = User::factory()->create(); + $response = $this->actingAs($user) + ->post(route('settings.labels.import'), [ + 'file' => new UploadedFile(base_path('tests/fixtures/labels-sample.bin'), 'labels.bin', null, null, true), + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['new', 'updated', 'total']); +}); + +test('label import returns 422 on invalid file', function () { + $user = User::factory()->create(); + $response = $this->actingAs($user) + ->post(route('settings.labels.import'), [ + 'file' => UploadedFile::fake()->create('notalabels.bin', 1), + ]); + + $response->assertStatus(422); +}); diff --git a/tests/Feature/MacroAssignmentControllerTest.php b/tests/Feature/MacroAssignmentControllerTest.php new file mode 100644 index 0000000..decd6c7 --- /dev/null +++ b/tests/Feature/MacroAssignmentControllerTest.php @@ -0,0 +1,108 @@ +post(route('settings.macro-assignments.store'), []); + $response->assertRedirect(route('login')); +}); + +test('store creates macro assignment', function () { + $user = User::factory()->create(); + $macro = Macro::factory()->create(); + + $response = $this->actingAs($user) + ->postJson(route('settings.macro-assignments.store'), [ + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $response->assertStatus(200)->assertJson(['success' => true]); + expect(MacroAssignment::count())->toBe(1); + expect(MacroAssignment::first()->part_type)->toBe('song'); +}); + +test('store with by_label position and label_id', function () { + $user = User::factory()->create(); + $macro = Macro::factory()->create(); + $label = Label::factory()->create(); + + $response = $this->actingAs($user) + ->postJson(route('settings.macro-assignments.store'), [ + 'part_type' => 'sermon', + 'macro_id' => $macro->id, + 'position' => 'by_label', + 'label_id' => $label->id, + 'order' => 1, + ]); + + $response->assertStatus(200); + $assignment = MacroAssignment::first(); + expect($assignment->label_id)->toBe($label->id); + expect($assignment->position)->toBe('by_label'); +}); + +test('update modifies existing assignment', function () { + $user = User::factory()->create(); + $macro = Macro::factory()->create(); + $assignment = MacroAssignment::create([ + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $response = $this->actingAs($user) + ->patchJson(route('settings.macro-assignments.update', $assignment), [ + 'position' => 'first_slide', + 'order' => 5, + ]); + + $response->assertStatus(200)->assertJson(['success' => true]); + expect($assignment->fresh()->position)->toBe('first_slide'); + expect($assignment->fresh()->order)->toBe(5); +}); + +test('destroy deletes macro assignment', function () { + $user = User::factory()->create(); + $macro = Macro::factory()->create(); + $assignment = MacroAssignment::create([ + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $response = $this->actingAs($user) + ->deleteJson(route('settings.macro-assignments.destroy', $assignment)); + + $response->assertStatus(200)->assertJson(['success' => true]); + expect(MacroAssignment::count())->toBe(0); +}); + +test('reorder updates order on multiple assignments', function () { + $user = User::factory()->create(); + $macro = Macro::factory()->create(); + $a1 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]); + $a2 = MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 1]); + + $response = $this->actingAs($user) + ->postJson(route('settings.macro-assignments.reorder'), [ + 'assignments' => [ + ['id' => $a1->id, 'order' => 5], + ['id' => $a2->id, 'order' => 3], + ], + ]); + + $response->assertStatus(200); + expect($a1->fresh()->order)->toBe(5); + expect($a2->fresh()->order)->toBe(3); +}); diff --git a/tests/Feature/MacroImportControllerTest.php b/tests/Feature/MacroImportControllerTest.php new file mode 100644 index 0000000..56a026b --- /dev/null +++ b/tests/Feature/MacroImportControllerTest.php @@ -0,0 +1,41 @@ +post(route('settings.macros.import'), []); + $response->assertRedirect(route('login')); +}); + +test('macro import returns json with stats on valid file', function () { + $user = User::factory()->create(); + $response = $this->actingAs($user) + ->post(route('settings.macros.import'), [ + 'file' => new UploadedFile(base_path('tests/fixtures/macros-sample.bin'), 'macros.bin', null, null, true), + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['stats' => ['new', 'updated', 'disabled', 're_enabled'], 'warnings']); +}); + +test('macro import returns 422 on invalid file', function () { + $user = User::factory()->create(); + $response = $this->actingAs($user) + ->post(route('settings.macros.import'), [ + 'file' => UploadedFile::fake()->create('notamacro.bin', 1), + ]); + + $response->assertStatus(422); +}); + +test('macro import without file returns validation error', function () { + $user = User::factory()->create(); + $response = $this->actingAs($user) + ->postJson(route('settings.macros.import'), []); + + $response->assertStatus(422); +}); diff --git a/tests/Feature/ServiceMacroOverrideControllerTest.php b/tests/Feature/ServiceMacroOverrideControllerTest.php new file mode 100644 index 0000000..904bc21 --- /dev/null +++ b/tests/Feature/ServiceMacroOverrideControllerTest.php @@ -0,0 +1,104 @@ +create(); + $response = $this->post(route('services.macro-overrides.store', $service), ['part_type' => 'song']); + $response->assertRedirect(route('login')); +}); + +test('store creates override and snapshots global assignments', function () { + $user = User::factory()->create(); + $service = Service::factory()->create(); + $macro = Macro::factory()->create(); + MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]); + MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'first_slide', 'order' => 1]); + + $response = $this->actingAs($user) + ->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']); + + $response->assertStatus(200)->assertJson(['success' => true]); + expect(ServiceMacroOverride::where('service_id', $service->id)->where('part_type', 'song')->exists())->toBeTrue(); + expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(2); +}); + +test('store does not snapshot assignments from different part_type', function () { + $user = User::factory()->create(); + $service = Service::factory()->create(); + $macro = Macro::factory()->create(); + MacroAssignment::create(['part_type' => 'song', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]); + MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macro->id, 'position' => 'all_slides', 'order' => 0]); + + $this->actingAs($user) + ->postJson(route('services.macro-overrides.store', $service), ['part_type' => 'song']); + + expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(1); + expect(ServiceMacroAssignment::where('service_id', $service->id)->first()->part_type)->toBe('song'); +}); + +test('destroy removes override and service-specific assignments', function () { + $user = User::factory()->create(); + $service = Service::factory()->create(); + $macro = Macro::factory()->create(); + ServiceMacroOverride::create(['service_id' => $service->id, 'part_type' => 'song']); + ServiceMacroAssignment::create([ + 'service_id' => $service->id, + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $response = $this->actingAs($user) + ->deleteJson(route('services.macro-overrides.destroy', $service), ['part_type' => 'song']); + + $response->assertStatus(200); + expect(ServiceMacroOverride::where('service_id', $service->id)->count())->toBe(0); + expect(ServiceMacroAssignment::where('service_id', $service->id)->count())->toBe(0); +}); + +test('storeAssignment creates service-level assignment', function () { + $user = User::factory()->create(); + $service = Service::factory()->create(); + $macro = Macro::factory()->create(); + + $response = $this->actingAs($user) + ->postJson(route('services.macro-assignments.store', $service), [ + 'part_type' => 'sermon', + 'macro_id' => $macro->id, + 'position' => 'last_slide', + 'order' => 0, + ]); + + $response->assertStatus(200)->assertJson(['success' => true]); + expect(ServiceMacroAssignment::count())->toBe(1); + expect(ServiceMacroAssignment::first()->service_id)->toBe($service->id); +}); + +test('destroyAssignment removes service-level assignment', function () { + $user = User::factory()->create(); + $service = Service::factory()->create(); + $macro = Macro::factory()->create(); + $assignment = ServiceMacroAssignment::create([ + 'service_id' => $service->id, + 'part_type' => 'song', + 'macro_id' => $macro->id, + 'position' => 'all_slides', + 'order' => 0, + ]); + + $response = $this->actingAs($user) + ->deleteJson(route('services.macro-assignments.destroy', [$service, $assignment])); + + $response->assertStatus(200); + expect(ServiceMacroAssignment::count())->toBe(0); +});