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.
This commit is contained in:
Thorsten Bus 2026-05-03 23:17:04 +02:00
parent cef247336e
commit 6ce5b6e018
9 changed files with 578 additions and 0 deletions

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Services\LabelsImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class LabelImportController extends Controller
{
public function __construct(
private readonly LabelsImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->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,
]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\MacroCollection;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MacroAssignmentController extends Controller
{
public function index(): Response
{
return Inertia::render('Settings', [
'assignments' => 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]);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Services\MacrosImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
class MacroImportController extends Controller
{
public function __construct(
private readonly MacrosImportService $importService,
) {}
public function store(Request $request): JsonResponse
{
$request->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,
]);
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceMacroOverrideController extends Controller
{
public function store(Request $request, Service $service): JsonResponse
{
$validated = $request->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]);
}
}

View file

@ -2,7 +2,11 @@
use App\Http\Controllers\ApiLogController; use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\AuthController; 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\ServiceController;
use App\Http\Controllers\ServiceMacroOverrideController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController; use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
@ -90,4 +94,34 @@
Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder'); 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::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'); 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');
}); });

View file

@ -0,0 +1,33 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
uses(RefreshDatabase::class);
test('label import requires authentication', function () {
$response = $this->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);
});

View file

@ -0,0 +1,108 @@
<?php
use App\Models\Label;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('store requires authentication', function () {
$response = $this->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);
});

View file

@ -0,0 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
uses(RefreshDatabase::class);
test('macro import requires authentication', function () {
$response = $this->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);
});

View file

@ -0,0 +1,104 @@
<?php
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceMacroAssignment;
use App\Models\ServiceMacroOverride;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('override requires authentication', function () {
$service = Service::factory()->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);
});