From 31d7634dbfbc250edf516b53a9202e379666b96f Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 29 Mar 2026 11:34:55 +0200 Subject: [PATCH] feat(db): add service_agenda_items table + slides FK migration --- app/Http/Controllers/ServiceController.php | 4 +- app/Models/ServiceAgendaItem.php | 57 +++++++ app/Models/Slide.php | 6 + app/Services/PlaylistExportService.php | 2 +- app/Services/ProBundleExportService.php | 2 +- .../factories/ServiceAgendaItemFactory.php | 51 ++++++ ...0001_create_service_agenda_items_table.php | 36 ++++ ...service_agenda_item_id_to_slides_table.php | 22 +++ tests/Feature/ProBundleExportTest.php | 4 +- tests/Feature/ServiceAgendaItemTest.php | 161 ++++++++++++++++++ 10 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 app/Models/ServiceAgendaItem.php create mode 100644 database/factories/ServiceAgendaItemFactory.php create mode 100644 database/migrations/2026_03_29_100001_create_service_agenda_items_table.php create mode 100644 database/migrations/2026_03_29_100002_add_service_agenda_item_id_to_slides_table.php create mode 100644 tests/Feature/ServiceAgendaItemTest.php diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index c285498..3725fd2 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -7,8 +7,8 @@ use App\Models\Song; use App\Services\ProBundleExportService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Inertia\Inertia; @@ -275,7 +275,7 @@ public function download(Service $service): JsonResponse|BinaryFileResponse } try { - $playlistService = new \App\Services\PlaylistExportService(); + $playlistService = new \App\Services\PlaylistExportService; $result = $playlistService->generatePlaylist($service); $response = response()->download($result['path'], $result['filename']); diff --git a/app/Models/ServiceAgendaItem.php b/app/Models/ServiceAgendaItem.php new file mode 100644 index 0000000..41820cf --- /dev/null +++ b/app/Models/ServiceAgendaItem.php @@ -0,0 +1,57 @@ + 'array', + 'is_before_event' => 'boolean', + ]; + } + + public function service(): BelongsTo + { + return $this->belongsTo(Service::class); + } + + public function serviceSong(): BelongsTo + { + return $this->belongsTo(ServiceSong::class); + } + + public function slides(): HasMany + { + return $this->hasMany(Slide::class, 'service_agenda_item_id'); + } + + public function scopeVisible(Builder $query): void + { + $query->where('is_before_event', false); + } +} diff --git a/app/Models/Slide.php b/app/Models/Slide.php index 4702923..90c0845 100644 --- a/app/Models/Slide.php +++ b/app/Models/Slide.php @@ -15,6 +15,7 @@ class Slide extends Model protected $fillable = [ 'type', 'service_id', + 'service_agenda_item_id', 'original_filename', 'stored_filename', 'thumbnail_filename', @@ -36,4 +37,9 @@ public function service(): BelongsTo { return $this->belongsTo(Service::class); } + + public function serviceAgendaItem(): BelongsTo + { + return $this->belongsTo(ServiceAgendaItem::class); + } } diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 0160ff1..ada2170 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -24,7 +24,7 @@ public function generatePlaylist(Service $service): array $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count(); $skippedEmpty = 0; - $exportService = new ProExportService(); + $exportService = new ProExportService; $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid(); mkdir($tempDir, 0755, true); diff --git a/app/Services/ProBundleExportService.php b/app/Services/ProBundleExportService.php index 4e73422..78fd06b 100644 --- a/app/Services/ProBundleExportService.php +++ b/app/Services/ProBundleExportService.php @@ -69,7 +69,7 @@ public function generateBundle(Service $service, string $blockType): string $bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle'; - $zip = new ZipArchive(); + $zip = new ZipArchive; $openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE); if ($openResult !== true) { throw new InvalidArgumentException('Konnte .probundle nicht erstellen.'); diff --git a/database/factories/ServiceAgendaItemFactory.php b/database/factories/ServiceAgendaItemFactory.php new file mode 100644 index 0000000..0d2bd72 --- /dev/null +++ b/database/factories/ServiceAgendaItemFactory.php @@ -0,0 +1,51 @@ + Service::factory(), + 'cts_agenda_item_id' => $this->faker->uuid(), + 'position' => $this->faker->numerify('#.#'), + 'title' => $this->faker->sentence(3), + 'type' => $this->faker->randomElement(['Song', 'Default', 'Header']), + 'note' => $this->faker->optional()->sentence(), + 'duration' => $this->faker->optional()->numerify('#'), + 'start' => $this->faker->optional()->time(), + 'is_before_event' => false, + 'responsible' => $this->faker->optional()->randomElements( + ['person1', 'person2', 'person3'], + $this->faker->numberBetween(1, 2) + ), + 'service_song_id' => null, + 'sort_order' => $this->faker->numberBetween(1, 20), + ]; + } + + public function withSong(ServiceSong $serviceSong): self + { + return $this->state(fn () => [ + 'service_song_id' => $serviceSong->id, + 'service_id' => $serviceSong->service_id, + 'type' => 'Song', + ]); + } + + public function nonSong(): self + { + return $this->state(fn () => [ + 'service_song_id' => null, + 'type' => $this->faker->randomElement(['Default', 'Header']), + ]); + } +} diff --git a/database/migrations/2026_03_29_100001_create_service_agenda_items_table.php b/database/migrations/2026_03_29_100001_create_service_agenda_items_table.php new file mode 100644 index 0000000..3682965 --- /dev/null +++ b/database/migrations/2026_03_29_100001_create_service_agenda_items_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('service_id')->constrained()->cascadeOnDelete(); + $table->string('cts_agenda_item_id')->nullable()->index(); + $table->string('position'); + $table->string('title'); + $table->string('type'); + $table->text('note')->nullable(); + $table->string('duration')->nullable(); + $table->string('start')->nullable(); + $table->boolean('is_before_event')->default(false); + $table->json('responsible')->nullable(); + $table->foreignId('service_song_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedInteger('sort_order'); + $table->timestamps(); + + $table->index('service_id'); + $table->unique(['service_id', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_agenda_items'); + } +}; diff --git a/database/migrations/2026_03_29_100002_add_service_agenda_item_id_to_slides_table.php b/database/migrations/2026_03_29_100002_add_service_agenda_item_id_to_slides_table.php new file mode 100644 index 0000000..1e3ecba --- /dev/null +++ b/database/migrations/2026_03_29_100002_add_service_agenda_item_id_to_slides_table.php @@ -0,0 +1,22 @@ +foreignId('service_agenda_item_id')->nullable()->after('service_id')->constrained()->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('slides', function (Blueprint $table) { + $table->dropConstrainedForeignId('service_agenda_item_id'); + }); + } +}; diff --git a/tests/Feature/ProBundleExportTest.php b/tests/Feature/ProBundleExportTest.php index 96c2b4e..a6e92f8 100644 --- a/tests/Feature/ProBundleExportTest.php +++ b/tests/Feature/ProBundleExportTest.php @@ -60,7 +60,7 @@ public function test_probundle_enthaelt_data_und_bilder(): void $copiedPath = sys_get_temp_dir().'/probundle-test-'.uniqid().'.probundle'; copy($baseResponse->getFile()->getPathname(), $copiedPath); - $zip = new ZipArchive(); + $zip = new ZipArchive; $openResult = $zip->open($copiedPath); $this->assertTrue($openResult === true); @@ -128,7 +128,7 @@ public function test_probundle_ohne_slides_enthaelt_nur_data(): void $copiedPath = sys_get_temp_dir().'/probundle-empty-test-'.uniqid().'.probundle'; copy($baseResponse->getFile()->getPathname(), $copiedPath); - $zip = new ZipArchive(); + $zip = new ZipArchive; $openResult = $zip->open($copiedPath); $this->assertTrue($openResult === true); diff --git a/tests/Feature/ServiceAgendaItemTest.php b/tests/Feature/ServiceAgendaItemTest.php new file mode 100644 index 0000000..b769ad3 --- /dev/null +++ b/tests/Feature/ServiceAgendaItemTest.php @@ -0,0 +1,161 @@ +toBeTrue(); + + $columns = [ + 'id', + 'service_id', + 'cts_agenda_item_id', + 'position', + 'title', + 'type', + 'note', + 'duration', + 'start', + 'is_before_event', + 'responsible', + 'service_song_id', + 'sort_order', + 'created_at', + 'updated_at', + ]; + + foreach ($columns as $column) { + expect(Schema::hasColumn('service_agenda_items', $column))->toBeTrue(); + } +}); + +test('factory creates valid service agenda item', function () { + $item = ServiceAgendaItem::factory()->create(); + + expect($item)->toBeInstanceOf(ServiceAgendaItem::class); + expect($item->id)->toBeGreaterThan(0); + expect($item->service_id)->toBeGreaterThan(0); + expect($item->title)->not->toBeNull(); + expect($item->type)->toBeIn(['Song', 'Default', 'Header']); + expect($item->position)->not->toBeNull(); + expect($item->is_before_event)->toBeFalse(); + expect($item->sort_order)->toBeGreaterThan(0); +}); + +test('service relationship returns correct service', function () { + $service = Service::factory()->create(); + $item = ServiceAgendaItem::factory()->create(['service_id' => $service->id]); + + expect($item->service)->toBeInstanceOf(Service::class); + expect($item->service->id)->toBe($service->id); +}); + +test('serviceSong relationship is nullable', function () { + $item = ServiceAgendaItem::factory()->create(['service_song_id' => null]); + + expect($item->serviceSong)->toBeNull(); +}); + +test('serviceSong relationship returns correct service song', function () { + $song = ServiceSong::factory()->create(); + $item = ServiceAgendaItem::factory()->create(['service_song_id' => $song->id]); + + expect($item->serviceSong)->toBeInstanceOf(ServiceSong::class); + expect($item->serviceSong->id)->toBe($song->id); +}); + +test('slides relationship returns associated slides', function () { + $item = ServiceAgendaItem::factory()->create(); + Slide::factory()->count(3)->create(['service_agenda_item_id' => $item->id]); + + expect($item->slides)->toHaveCount(3); + expect($item->slides->first())->toBeInstanceOf(Slide::class); +}); + +test('scopeVisible excludes is_before_event true items', function () { + ServiceAgendaItem::factory()->count(2)->create(['is_before_event' => false]); + ServiceAgendaItem::factory()->count(3)->create(['is_before_event' => true]); + + $visible = ServiceAgendaItem::visible()->get(); + + expect($visible)->toHaveCount(2); + $visible->each(fn ($item) => expect($item->is_before_event)->toBeFalse()); +}); + +test('responsible is cast to array', function () { + $item = ServiceAgendaItem::factory()->create([ + 'responsible' => ['person1', 'person2'], + ]); + + expect($item->responsible)->toBeArray(); + expect($item->responsible)->toHaveCount(2); +}); + +test('is_before_event is cast to boolean', function () { + $item = ServiceAgendaItem::factory()->create(['is_before_event' => true]); + + expect($item->is_before_event)->toBeTrue(); + expect(is_bool($item->is_before_event))->toBeTrue(); +}); + +test('unique constraint on service_id and sort_order', function () { + $service = Service::factory()->create(); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'sort_order' => 1, + ]); + + expect(fn () => ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'sort_order' => 1, + ]))->toThrow(QueryException::class); +}); + +test('withSong factory state', function () { + $song = ServiceSong::factory()->create(); + $item = ServiceAgendaItem::factory()->withSong($song)->create(); + + expect($item->service_song_id)->toBe($song->id); + expect($item->service_id)->toBe($song->service_id); + expect($item->type)->toBe('Song'); +}); + +test('nonSong factory state', function () { + $item = ServiceAgendaItem::factory()->nonSong()->create(); + + expect($item->service_song_id)->toBeNull(); + expect($item->type)->toBeIn(['Default', 'Header']); +}); + +test('cascadeOnDelete removes agenda items when service deleted', function () { + $service = Service::factory()->create(); + ServiceAgendaItem::factory()->count(3)->create(['service_id' => $service->id]); + + expect(ServiceAgendaItem::where('service_id', $service->id)->count())->toBe(3); + + $service->delete(); + + expect(ServiceAgendaItem::where('service_id', $service->id)->count())->toBe(0); +}); + +test('nullOnDelete nullifies service_song_id when song deleted', function () { + $song = ServiceSong::factory()->create(); + $item = ServiceAgendaItem::factory()->create(['service_song_id' => $song->id]); + + expect($item->service_song_id)->toBe($song->id); + + $song->delete(); + $item->refresh(); + + expect($item->service_song_id)->toBeNull(); +});